This is an automated email from the ASF dual-hosted git repository.

derrickaw pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 2b5c1131af3 [yaml] - mongodb write normalization (#38376)
2b5c1131af3 is described below

commit 2b5c1131af3cc0d64a97a6b2d82b082274761635
Author: Derrick Williams <[email protected]>
AuthorDate: Fri May 29 13:43:10 2026 -0400

    [yaml] - mongodb write normalization (#38376)
    
    * MongoDB IO write connector for beam yaml
    
    * revert bigtable change
    
    * add yaml and more transformer write support
    
    * more edits to support parity between java and python
    
    * remove read
    
    * remove updateConfiguration
    
    * remove updateField
    
    * update external transforms
    
    * remove unnecessary comments
    
    * add clarifying comments
    
    * fix gemini comments
    
    * address gemini comments
    
    * address reviewer comments
    
    * Updated to 1024 batch size
    
    * remove duplicate function due to rebasing
    
    ---------
    
    Co-authored-by: Arnav Arora <[email protected]>
---
 sdks/java/io/expansion-service/build.gradle        |   1 +
 .../apache/beam/sdk/io/mongodb/MongoDbUtils.java   |  74 +++++++++
 .../MongoDbWriteSchemaTransformConfiguration.java  |  85 +++++++++++
 .../MongoDbWriteSchemaTransformProvider.java       | 168 +++++++++++++++++++++
 .../beam/sdk/io/mongodb/MongoDbUtilsTest.java      | 135 +++++++++++++++++
 .../MongoDbWriteSchemaTransformProviderTest.java   | 166 ++++++++++++++++++++
 sdks/python/apache_beam/yaml/integration_tests.py  |  50 ++++++
 sdks/python/apache_beam/yaml/standard_io.yaml      |  19 +++
 sdks/python/apache_beam/yaml/tests/mongodb.yaml    |  44 ++++++
 sdks/python/apache_beam/yaml/yaml_io.py            |  48 ++++++
 sdks/standard_external_transforms.yaml             |  32 +++-
 11 files changed, 821 insertions(+), 1 deletion(-)

diff --git a/sdks/java/io/expansion-service/build.gradle 
b/sdks/java/io/expansion-service/build.gradle
index 1045ad4aeed..32894b97809 100644
--- a/sdks/java/io/expansion-service/build.gradle
+++ b/sdks/java/io/expansion-service/build.gradle
@@ -96,6 +96,7 @@ dependencies {
   runtimeOnly project(path: ":sdks:java:io:iceberg:bqms", configuration: 
"shadow")
   runtimeOnly library.java.bigdataoss_util_hadoop
 
+  runtimeOnly project(":sdks:java:io:mongodb")
   runtimeOnly library.java.kafka_clients
   runtimeOnly library.java.slf4j_jdk14
 
diff --git 
a/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbUtils.java
 
b/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbUtils.java
new file mode 100644
index 00000000000..a5acfb1d19f
--- /dev/null
+++ 
b/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbUtils.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.beam.sdk.io.mongodb;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.apache.beam.sdk.schemas.Schema.Field;
+import org.apache.beam.sdk.values.Row;
+import org.bson.BsonNull;
+import org.bson.Document;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Utility methods for MongoDB IO. */
+public class MongoDbUtils {
+
+  /** Converts a Beam {@link Row} to a BSON {@link Document}. */
+  public static Document toDocument(Row row) {
+    Object converted = convertToBsonValue(row);
+    if (converted instanceof Document) {
+      return (Document) converted;
+    }
+    throw new IllegalArgumentException(
+        "Expected Document but got "
+            + (converted != null ? converted.getClass().getName() : "null"));
+  }
+
+  private static @Nullable Object convertToBsonValue(@Nullable Object value) {
+    if (value == null) {
+      return new BsonNull();
+    }
+    if (value instanceof Row) {
+      Row row = (Row) value;
+      Document doc = new Document();
+      for (Field field : row.getSchema().getFields()) {
+        Object fieldValue = row.getValue(field.getName());
+        Object converted = convertToBsonValue(fieldValue);
+        doc.append(field.getName(), converted != null ? converted : new 
BsonNull());
+      }
+      return doc;
+    } else if (value instanceof Iterable) {
+      List<Object> bsonList = new ArrayList<>();
+      for (Object item : (Iterable<?>) value) {
+        Object converted = convertToBsonValue(item);
+        bsonList.add(converted != null ? converted : new BsonNull());
+      }
+      return bsonList;
+    } else if (value instanceof Map) {
+      Map<?, ?> map = (Map<?, ?>) value;
+      Document doc = new Document();
+      for (Map.Entry<?, ?> entry : map.entrySet()) {
+        Object converted = convertToBsonValue(entry.getValue());
+        doc.append(String.valueOf(entry.getKey()), converted != null ? 
converted : new BsonNull());
+      }
+      return doc;
+    }
+    return value;
+  }
+}
diff --git 
a/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformConfiguration.java
 
b/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformConfiguration.java
new file mode 100644
index 00000000000..275cfe88001
--- /dev/null
+++ 
b/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformConfiguration.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.beam.sdk.io.mongodb;
+
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import org.apache.beam.sdk.schemas.AutoValueSchema;
+import org.apache.beam.sdk.schemas.annotations.DefaultSchema;
+import org.apache.beam.sdk.schemas.annotations.SchemaFieldDescription;
+import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/** Configuration class for the MongoDB Write transform. */
+@DefaultSchema(AutoValueSchema.class)
+@AutoValue
+public abstract class MongoDbWriteSchemaTransformConfiguration implements 
Serializable {
+
+  @SchemaFieldDescription("The connection URI for the MongoDB server.")
+  public abstract String getUri();
+
+  @SchemaFieldDescription("The MongoDB database to write to.")
+  public abstract String getDatabase();
+
+  @SchemaFieldDescription("The MongoDB collection to write to.")
+  public abstract String getCollection();
+
+  @SchemaFieldDescription("The number of documents to include in each batch 
write.")
+  @Nullable
+  public abstract Long getBatchSize();
+
+  @SchemaFieldDescription(
+      "This option specifies whether and where to output unwritable rows. 
Note: Error handling is currently limited to data conversion failures before 
sending to the MongoDB driver, as the underlying MongoDbIO does not yet support 
dead-letter queues for write failures.")
+  @Nullable
+  public abstract ErrorHandling getErrorHandling();
+
+  public void validate() {
+    checkArgument(getUri() != null && !getUri().isEmpty(), "MongoDB URI must 
be specified.");
+    checkArgument(
+        getDatabase() != null && !getDatabase().isEmpty(), "MongoDB database 
must be specified.");
+    checkArgument(
+        getCollection() != null && !getCollection().isEmpty(),
+        "MongoDB collection must be specified.");
+
+    Long batchSize = getBatchSize();
+    if (batchSize != null) {
+      checkArgument(batchSize > 0, "Batch size must be positive.");
+    }
+  }
+
+  public static Builder builder() {
+    return new AutoValue_MongoDbWriteSchemaTransformConfiguration.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setUri(String uri);
+
+    public abstract Builder setDatabase(String database);
+
+    public abstract Builder setCollection(String collection);
+
+    public abstract Builder setBatchSize(Long batchSize);
+
+    public abstract Builder setErrorHandling(ErrorHandling errorHandling);
+
+    public abstract MongoDbWriteSchemaTransformConfiguration build();
+  }
+}
diff --git 
a/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformProvider.java
 
b/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformProvider.java
new file mode 100644
index 00000000000..dde265ce2b8
--- /dev/null
+++ 
b/sdks/java/io/mongodb/src/main/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformProvider.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.beam.sdk.io.mongodb;
+
+import com.google.auto.service.AutoService;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+import org.apache.beam.sdk.coders.AtomicCoder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransform;
+import org.apache.beam.sdk.schemas.transforms.SchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.TypedSchemaTransformProvider;
+import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionRowTuple;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TupleTag;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.bson.Document;
+
+/** An implementation of {@link TypedSchemaTransformProvider} for writing to 
MongoDB. */
+@AutoService(SchemaTransformProvider.class)
+public class MongoDbWriteSchemaTransformProvider
+    extends 
TypedSchemaTransformProvider<MongoDbWriteSchemaTransformConfiguration> {
+
+  public static class DocumentCoder extends AtomicCoder<Document> implements 
Serializable {
+    private static final DocumentCoder INSTANCE = new DocumentCoder();
+
+    private DocumentCoder() {}
+
+    public static DocumentCoder of() {
+      return INSTANCE;
+    }
+
+    @Override
+    public void encode(Document value, OutputStream outStream) throws 
java.io.IOException {
+      StringUtf8Coder.of().encode(value.toJson(), outStream);
+    }
+
+    @Override
+    public Document decode(InputStream inStream) throws java.io.IOException {
+      String json = StringUtf8Coder.of().decode(inStream);
+      return Document.parse(json);
+    }
+  }
+
+  private static final String INPUT_TAG = "input";
+  public static final TupleTag<Document> OUTPUT_TAG = new TupleTag<Document>() 
{};
+  public static final TupleTag<Row> ERROR_TAG = new TupleTag<Row>() {};
+
+  private static final org.apache.beam.sdk.metrics.Counter errorCounter =
+      org.apache.beam.sdk.metrics.Metrics.counter(
+          MongoDbWriteSchemaTransformProvider.class, 
"MongoDB-write-error-counter");
+
+  @Override
+  protected SchemaTransform from(MongoDbWriteSchemaTransformConfiguration 
configuration) {
+    return new MongoDbWriteSchemaTransform(configuration);
+  }
+
+  @Override
+  public String identifier() {
+    return "beam:schematransform:org.apache.beam:mongodb_write:v1";
+  }
+
+  @Override
+  public List<String> inputCollectionNames() {
+    return Collections.singletonList(INPUT_TAG);
+  }
+
+  /** The {@link SchemaTransform} that performs the write operation. */
+  private static class MongoDbWriteSchemaTransform extends SchemaTransform {
+    private final MongoDbWriteSchemaTransformConfiguration configuration;
+
+    MongoDbWriteSchemaTransform(MongoDbWriteSchemaTransformConfiguration 
configuration) {
+      configuration.validate();
+      this.configuration = configuration;
+    }
+
+    @Override
+    public PCollectionRowTuple expand(PCollectionRowTuple input) {
+      // Retrieve the input PCollection of Rows and its schema.
+      PCollection<Row> rows = input.get(INPUT_TAG);
+      org.apache.beam.sdk.schemas.Schema inputSchema = rows.getSchema();
+
+      // Determine if error handling is enabled and set up the error schema.
+      boolean handleErrors = 
ErrorHandling.hasOutput(configuration.getErrorHandling());
+      org.apache.beam.sdk.schemas.Schema errorSchema = 
ErrorHandling.errorSchema(inputSchema);
+
+      // Convert Beam Rows to BSON Documents, emitting errors to a separate 
tag if enabled.
+      PCollectionTuple outputTuple =
+          rows.apply(
+              "ConvertToDocument",
+              ParDo.of(new RowToBsonDocumentFn(handleErrors, errorSchema))
+                  .withOutputTags(OUTPUT_TAG, TupleTagList.of(ERROR_TAG)));
+
+      PCollection<Document> documents = 
outputTuple.get(OUTPUT_TAG).setCoder(DocumentCoder.of());
+
+      // Configure the MongoDB write operation.
+      MongoDbIO.Write write =
+          MongoDbIO.write()
+              .withUri(configuration.getUri())
+              .withDatabase(configuration.getDatabase())
+              .withCollection(configuration.getCollection());
+
+      Long batchSize = configuration.getBatchSize();
+      if (batchSize != null) {
+        write = write.withBatchSize(batchSize);
+      }
+
+      // Apply the MongoDB write transform.
+      documents.apply("WriteToMongo", write);
+
+      // Extract and format the error collection.
+      PCollection<Row> errorOutput = 
outputTuple.get(ERROR_TAG).setRowSchema(errorSchema);
+
+      // Return the error collection as specified by the configuration.
+      ErrorHandling errorHandling = configuration.getErrorHandling();
+      return PCollectionRowTuple.of(
+          (handleErrors && errorHandling != null) ? errorHandling.getOutput() 
: "errors",
+          errorOutput);
+    }
+  }
+
+  /** Converts a Beam {@link Row} to a BSON {@link Document}. */
+  static class RowToBsonDocumentFn extends DoFn<Row, Document> {
+    private final boolean handleErrors;
+    private final org.apache.beam.sdk.schemas.Schema errorSchema;
+
+    RowToBsonDocumentFn(boolean handleErrors, 
org.apache.beam.sdk.schemas.Schema errorSchema) {
+      this.handleErrors = handleErrors;
+      this.errorSchema = errorSchema;
+    }
+
+    @ProcessElement
+    public void processElement(@Element Row row, MultiOutputReceiver receiver) 
{
+      try {
+        receiver.get(OUTPUT_TAG).output(MongoDbUtils.toDocument(row));
+      } catch (Exception e) {
+        if (!handleErrors) {
+          throw new RuntimeException(e);
+        }
+        errorCounter.inc();
+        receiver.get(ERROR_TAG).output(ErrorHandling.errorRecord(errorSchema, 
row, e));
+      }
+    }
+  }
+}
diff --git 
a/sdks/java/io/mongodb/src/test/java/org/apache/beam/sdk/io/mongodb/MongoDbUtilsTest.java
 
b/sdks/java/io/mongodb/src/test/java/org/apache/beam/sdk/io/mongodb/MongoDbUtilsTest.java
new file mode 100644
index 00000000000..ec11bb86591
--- /dev/null
+++ 
b/sdks/java/io/mongodb/src/test/java/org/apache/beam/sdk/io/mongodb/MongoDbUtilsTest.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.beam.sdk.io.mongodb;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.Schema.FieldType;
+import org.apache.beam.sdk.values.Row;
+import org.bson.BsonNull;
+import org.bson.Document;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link MongoDbUtils}. */
+@RunWith(JUnit4.class)
+public class MongoDbUtilsTest {
+
+  @Test
+  public void testToDocumentWithSimplePrimitives() {
+    Schema schema =
+        Schema.builder()
+            .addStringField("stringField")
+            .addInt32Field("intField")
+            .addBooleanField("booleanField")
+            .addDoubleField("doubleField")
+            .build();
+
+    Row row = Row.withSchema(schema).addValues("hello", 42, true, 
3.14).build();
+
+    Document doc = MongoDbUtils.toDocument(row);
+
+    assertNotNull(doc);
+    assertEquals("hello", doc.get("stringField"));
+    assertEquals(42, doc.get("intField"));
+    assertEquals(true, doc.get("booleanField"));
+    assertEquals(3.14, doc.get("doubleField"));
+  }
+
+  @Test
+  public void testToDocumentWithNestedRow() {
+    Schema nestedSchema =
+        
Schema.builder().addStringField("nestedString").addInt32Field("nestedInt").build();
+
+    Schema parentSchema =
+        Schema.builder()
+            .addStringField("parentString")
+            .addRowField("nestedRow", nestedSchema)
+            .build();
+
+    Row nestedRow = Row.withSchema(nestedSchema).addValues("nestedValue", 
100).build();
+    Row parentRow = Row.withSchema(parentSchema).addValues("parentValue", 
nestedRow).build();
+
+    Document doc = MongoDbUtils.toDocument(parentRow);
+
+    assertNotNull(doc);
+    assertEquals("parentValue", doc.get("parentString"));
+
+    Object nestedObj = doc.get("nestedRow");
+    assertTrue(nestedObj instanceof Document);
+    Document nestedDoc = (Document) nestedObj;
+    assertEquals("nestedValue", nestedDoc.get("nestedString"));
+    assertEquals(100, nestedDoc.get("nestedInt"));
+  }
+
+  @Test
+  public void testToDocumentWithIterable() {
+    Schema schema = Schema.builder().addArrayField("listField", 
FieldType.STRING).build();
+
+    Row row = Row.withSchema(schema).addValue(Arrays.asList("a", "b", 
"c")).build();
+
+    Document doc = MongoDbUtils.toDocument(row);
+
+    assertNotNull(doc);
+    Object listObj = doc.get("listField");
+    assertTrue(listObj instanceof List);
+    List<?> list = (List<?>) listObj;
+    assertEquals(3, list.size());
+    assertEquals("a", list.get(0));
+    assertEquals("b", list.get(1));
+    assertEquals("c", list.get(2));
+  }
+
+  @Test
+  public void testToDocumentWithMap() {
+    Schema schema =
+        Schema.builder().addMapField("mapField", FieldType.STRING, 
FieldType.INT32).build();
+
+    Map<String, Integer> map = Collections.singletonMap("key", 42);
+    Row row = Row.withSchema(schema).addValue(map).build();
+
+    Document doc = MongoDbUtils.toDocument(row);
+
+    assertNotNull(doc);
+    Object mapObj = doc.get("mapField");
+    assertTrue(mapObj instanceof Document);
+    Document mapDoc = (Document) mapObj;
+    assertEquals(42, mapDoc.get("key"));
+  }
+
+  @Test
+  public void testToDocumentWithNullValues() {
+    Schema schema = Schema.builder().addNullableField("nullableString", 
FieldType.STRING).build();
+
+    Row row = Row.withSchema(schema).addValue(null).build();
+
+    Document doc = MongoDbUtils.toDocument(row);
+
+    assertNotNull(doc);
+    Object val = doc.get("nullableString");
+    assertTrue(val instanceof BsonNull);
+  }
+}
diff --git 
a/sdks/java/io/mongodb/src/test/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformProviderTest.java
 
b/sdks/java/io/mongodb/src/test/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformProviderTest.java
new file mode 100644
index 00000000000..864c516617e
--- /dev/null
+++ 
b/sdks/java/io/mongodb/src/test/java/org/apache/beam/sdk/io/mongodb/MongoDbWriteSchemaTransformProviderTest.java
@@ -0,0 +1,166 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.beam.sdk.io.mongodb;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+
+import java.util.Collections;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.schemas.SchemaRegistry;
+import org.apache.beam.sdk.schemas.transforms.providers.ErrorHandling;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionTuple;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TupleTagList;
+import org.bson.Document;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link MongoDbWriteSchemaTransformProvider}. */
+@RunWith(JUnit4.class)
+public class MongoDbWriteSchemaTransformProviderTest {
+
+  @Rule public transient TestPipeline p = TestPipeline.create();
+
+  @Test
+  public void testInvalidConfigMissingUri() {
+    assertThrows(
+        IllegalStateException.class,
+        () -> {
+          MongoDbWriteSchemaTransformConfiguration.builder()
+              .setDatabase("db")
+              .setCollection("col")
+              .build()
+              .validate();
+        });
+  }
+
+  @Test
+  public void testInvalidConfigMissingDatabase() {
+    assertThrows(
+        IllegalStateException.class,
+        () -> {
+          MongoDbWriteSchemaTransformConfiguration.builder()
+              .setUri("mongodb://localhost:27017")
+              .setCollection("col")
+              .build()
+              .validate();
+        });
+  }
+
+  @Test
+  public void testInvalidConfigMissingCollection() {
+    assertThrows(
+        IllegalStateException.class,
+        () -> {
+          MongoDbWriteSchemaTransformConfiguration.builder()
+              .setUri("mongodb://localhost:27017")
+              .setDatabase("db")
+              .build()
+              .validate();
+        });
+  }
+
+  @Test
+  public void testInvalidConfigNegativeBatchSize() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> {
+          MongoDbWriteSchemaTransformConfiguration.builder()
+              .setUri("mongodb://localhost:27017")
+              .setDatabase("db")
+              .setCollection("col")
+              .setBatchSize(-1L)
+              .build()
+              .validate();
+        });
+  }
+
+  @Test
+  public void testConfigurationSchema() throws Exception {
+    Schema schema =
+        
SchemaRegistry.createDefault().getSchema(MongoDbWriteSchemaTransformConfiguration.class);
+
+    // We expect 5 fields now (uri, database, collection, batchSize, 
errorHandling)
+    assertEquals(5, schema.getFieldCount());
+    assertNotNull(schema.getField("uri"));
+    assertNotNull(schema.getField("database"));
+    assertNotNull(schema.getField("collection"));
+    assertNotNull(schema.getField("batchSize"));
+    assertNotNull(schema.getField("errorHandling"));
+  }
+
+  @Test
+  public void testRowToBsonDocumentFn() {
+    Schema beamSchema =
+        Schema.builder()
+            .addStringField("name")
+            .addInt32Field("age")
+            .addNullableStringField("country")
+            .build();
+
+    Row row =
+        Row.withSchema(beamSchema)
+            .withFieldValue("name", "John")
+            .withFieldValue("age", 30)
+            .withFieldValue("country", null)
+            .build();
+
+    PCollection<Row> inputRows =
+        
p.apply(Create.of(Collections.singletonList(row))).setRowSchema(beamSchema);
+
+    Schema errorSchema = ErrorHandling.errorSchema(beamSchema);
+    PCollectionTuple outputTuple =
+        inputRows.apply(
+            "ConvertToDocument",
+            ParDo.of(
+                    new 
MongoDbWriteSchemaTransformProvider.RowToBsonDocumentFn(false, errorSchema))
+                .withOutputTags(
+                    MongoDbWriteSchemaTransformProvider.OUTPUT_TAG,
+                    
TupleTagList.of(MongoDbWriteSchemaTransformProvider.ERROR_TAG)));
+
+    PCollection<Document> bsonDocuments =
+        outputTuple
+            .get(MongoDbWriteSchemaTransformProvider.OUTPUT_TAG)
+            .setCoder(MongoDbWriteSchemaTransformProvider.DocumentCoder.of());
+
+    
outputTuple.get(MongoDbWriteSchemaTransformProvider.ERROR_TAG).setRowSchema(errorSchema);
+
+    PAssert.that(bsonDocuments)
+        .satisfies(
+            documents -> {
+              Document doc = documents.iterator().next();
+              assertEquals("John", doc.get("name"));
+              assertEquals(30, doc.get("age"));
+              // The RowToBsonDocumentFn retains nulls explicitly in the BSON 
document
+              assertEquals(true, doc.containsKey("country"));
+              assertEquals(null, doc.get("country"));
+              return null;
+            });
+
+    p.run().waitUntilFinish();
+  }
+}
diff --git a/sdks/python/apache_beam/yaml/integration_tests.py 
b/sdks/python/apache_beam/yaml/integration_tests.py
index 7aad41e0607..2d0b2787fc9 100644
--- a/sdks/python/apache_beam/yaml/integration_tests.py
+++ b/sdks/python/apache_beam/yaml/integration_tests.py
@@ -45,6 +45,7 @@ from testcontainers.core.container import DockerContainer
 from testcontainers.core.waiting_utils import wait_for_logs
 from testcontainers.google import PubSubContainer
 from testcontainers.kafka import KafkaContainer
+from testcontainers.mongodb import MongoDbContainer
 from testcontainers.mssql import SqlServerContainer
 from testcontainers.mysql import MySqlContainer
 from testcontainers.postgres import PostgresContainer
@@ -62,6 +63,8 @@ from apache_beam.yaml.conftest import yaml_test_files_dir
 
 _LOGGER = logging.getLogger(__name__)
 
+_MONGO_CONTAINER_IMAGE = 'mongo:7.0.7'
+
 
 @contextlib.contextmanager
 def gcs_temp_dir(bucket):
@@ -200,6 +203,53 @@ def temp_bigtable_table(project, prefix='yaml_bt_it_'):
     _LOGGER.warning("Failed to clean up instance")
 
 
[email protected]
+def temp_mongodb_table():
+  """
+  provides a temporary MongoDB instance.
+
+  starts a MongoDB container, creates a unique database
+  and collection name for test isolation, and yields them as a dictionary.
+
+  This allows YAML test files to get connection details without hardcoding 
them.
+  Example usage in a YAML test file's fixture section:
+
+  fixtures:
+    - name: mongo_vars
+      type: path.to.this.file.mongodb_fixture
+
+  Then, in the pipeline definition, you can use placeholders like:
+  - uri: ${mongo_vars.URI}
+  - database: ${mongo_vars.DATABASE}
+  - collection: ${mongo_vars.COLLECTION}
+  """
+  _LOGGER.info("Setting up MongoDB fixture...")
+  mongo_container = MongoDbContainer(_MONGO_CONTAINER_IMAGE)
+  try:
+    mongo_container.start()
+    mongo_uri = mongo_container.get_connection_url()
+
+    db_name = f'db_{uuid.uuid4().hex}'
+    collection_name = f'collection_{uuid.uuid4().hex}'
+
+    _LOGGER.info(
+        "MongoDB container started. URI: [%s], DB: [%s], Collection: [%s]",
+        mongo_uri,
+        db_name,
+        collection_name)
+
+    yield {
+        'URI': mongo_uri,
+        'DATABASE': db_name,
+        'COLLECTION': collection_name,
+    }
+
+  finally:
+    _LOGGER.info("Tearing down MongoDB fixture...")
+    mongo_container.stop()
+    _LOGGER.info("MongoDB container stopped.")
+
+
 @contextlib.contextmanager
 def temp_sqlite_database(prefix='yaml_jdbc_it_'):
   """Context manager to provide a temporary SQLite database via JDBC for
diff --git a/sdks/python/apache_beam/yaml/standard_io.yaml 
b/sdks/python/apache_beam/yaml/standard_io.yaml
index a48c3accff4..781d3de193e 100644
--- a/sdks/python/apache_beam/yaml/standard_io.yaml
+++ b/sdks/python/apache_beam/yaml/standard_io.yaml
@@ -115,6 +115,7 @@
     'WriteToIceberg': 'apache_beam.yaml.yaml_io.write_to_iceberg'
     'ReadFromTFRecord': 'apache_beam.yaml.yaml_io.read_from_tfrecord'
     'WriteToTFRecord': 'apache_beam.yaml.yaml_io.write_to_tfrecord'
+    'WriteToMongoDB': 'apache_beam.yaml.yaml_io.write_to_mongodb'
 
 
 # General File Formats
@@ -429,3 +430,21 @@
       config:
         gradle_target: 'sdks:java:io:expansion-service:shadowJar'
 
+#MongoDB
+- type: renaming
+  transforms:
+    'WriteToMongoDB': 'WriteToMongoDB'
+  config:
+    mappings:
+      'WriteToMongoDB':
+        connection_uri: "uri"
+        database: "database"
+        collection: "collection"
+        batch_size: "batch_size"
+        error_handling: "error_handling"
+    underlying_provider:
+      type: beamJar
+      transforms:
+        'WriteToMongoDB': 
'beam:schematransform:org.apache.beam:mongodb_write:v1'
+      config:
+        gradle_target: 'sdks:java:io:expansion-service:shadowJar'
diff --git a/sdks/python/apache_beam/yaml/tests/mongodb.yaml 
b/sdks/python/apache_beam/yaml/tests/mongodb.yaml
new file mode 100644
index 00000000000..efce73c8987
--- /dev/null
+++ b/sdks/python/apache_beam/yaml/tests/mongodb.yaml
@@ -0,0 +1,44 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+
+fixtures:
+  - name: mongo_vars
+    type: "apache_beam.yaml.integration_tests.temp_mongodb_table"
+
+pipelines:
+  - pipeline:
+      type: composite
+      transforms:
+        - type: Create
+          name: CreateData
+          config:
+            elements:
+              - { id: 1, name: "John" }
+              - { id: 2, name: "Jane" }
+        - type: WriteToMongoDB
+          name: WriteData
+          input: CreateData
+          config:
+            connection_uri: '{mongo_vars[URI]}'
+            database: '{mongo_vars[DATABASE]}'
+            collection: '{mongo_vars[COLLECTION]}'
+            error_handling:
+              output: my_error_output
+        - type: AssertEqual
+          input: WriteData.my_error_output
+          config:
+            elements: []
diff --git a/sdks/python/apache_beam/yaml/yaml_io.py 
b/sdks/python/apache_beam/yaml/yaml_io.py
index f59e730ac81..eaa0d431750 100644
--- a/sdks/python/apache_beam/yaml/yaml_io.py
+++ b/sdks/python/apache_beam/yaml/yaml_io.py
@@ -29,6 +29,7 @@ from collections.abc import Iterable
 from collections.abc import Mapping
 from typing import Any
 from typing import Optional
+from typing import Union
 
 import fastavro
 
@@ -725,6 +726,53 @@ def write_to_tfrecord(
           compression_type=getattr(CompressionTypes, compression_type))
 
 
[email protected]_fn
+@yaml_errors.maybe_with_exception_handling_transform_fn
+def write_to_mongodb(
+    pcoll,
+    *,
+    database: str,
+    collection: str,
+    connection_uri: Optional[str] = None,
+    batch_size: int = 1024,
+    extra_client_params: Optional[Mapping[str, Any]] = None):
+  """Writes data to MongoDB.
+
+  Args:
+    pcoll: The input PCollection of Beam Rows.
+    database: The MongoDB database name.
+    collection: The MongoDB collection name.
+    connection_uri: The MongoDB connection string. e.g. 
"mongodb://localhost:27017"
+    batch_size: Number of documents per bulk_write to MongoDB.
+    extra_client_params: Optional MongoClient parameters.
+  """
+  from apache_beam.io import mongodbio
+
+  def row_to_dict(value):
+    if value is None:
+      return None
+    if hasattr(value, '_asdict'):
+      return {k: row_to_dict(v) for k, v in value._asdict().items()}
+    elif hasattr(value, 'as_dict'):
+      return {k: row_to_dict(v) for k, v in value.as_dict().items()}
+    elif isinstance(value, (list, tuple)):
+      return [row_to_dict(v) for v in value]
+    elif isinstance(value, Mapping):
+      return {k: row_to_dict(v) for k, v in value.items()}
+    else:
+      return value
+
+  return (
+      pcoll
+      | beam.Map(row_to_dict)
+      | mongodbio.WriteToMongoDB(
+          uri=connection_uri,
+          db=database,
+          coll=collection,
+          batch_size=batch_size,
+          extra_client_params=extra_client_params))
+
+
 @beam.ptransform_fn
 def match_all(
     pcoll,
diff --git a/sdks/standard_external_transforms.yaml 
b/sdks/standard_external_transforms.yaml
index 1c536ce319d..057c4e3f47d 100644
--- a/sdks/standard_external_transforms.yaml
+++ b/sdks/standard_external_transforms.yaml
@@ -19,7 +19,7 @@
 # configuration in /sdks/standard_expansion_services.yaml.
 # Refer to gen_xlang_wrappers.py for more info.
 #
-# Last updated on: 2025-06-05
+# Last updated on: 2026-05-06
 
 - default_service: sdks:java:io:expansion-service:shadowJar
   description: 'Outputs a PCollection of Beam Rows, each containing a single 
INT64
@@ -50,6 +50,36 @@
     type: int64
   identifier: beam:schematransform:org.apache.beam:generate_sequence:v1
   name: GenerateSequence
+- default_service: sdks:java:io:expansion-service:shadowJar
+  description: ''
+  destinations:
+    python: apache_beam/io
+  fields:
+  - description: The number of documents to include in each batch write.
+    name: batch_size
+    nullable: true
+    type: int64
+  - description: The MongoDB collection to write to.
+    name: collection
+    nullable: false
+    type: str
+  - description: The MongoDB database to write to.
+    name: database
+    nullable: false
+    type: str
+  - description: 'This option specifies whether and where to output unwritable 
rows.
+      Note: Error handling is currently limited to data conversion failures 
before
+      sending to the MongoDB driver, as the underlying MongoDbIO does not yet 
support
+      dead-letter queues for write failures.'
+    name: error_handling
+    nullable: true
+    type: Row(output=<class 'str'>)
+  - description: The connection URI for the MongoDB server.
+    name: uri
+    nullable: false
+    type: str
+  identifier: beam:schematransform:org.apache.beam:mongodb_write:v1
+  name: MongodbWrite
 - default_service: sdks:java:io:expansion-service:shadowJar
   description: ''
   destinations:


Reply via email to