diff --git a/docs/docs-cn/source/5.application-development/3.connector/12.mongodb.md b/docs/docs-cn/source/5.application-development/3.connector/12.mongodb.md
new file mode 100644
index 000000000..4ff162086
--- /dev/null
+++ b/docs/docs-cn/source/5.application-development/3.connector/12.mongodb.md
@@ -0,0 +1,94 @@
+# MongoDB Connector 介绍
+
+MongoDB Connector 支持读取有界 collection,并将数据插入 MongoDB collection。
+
+## 语法
+
+```sql
+CREATE TABLE mongo_source (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'source_records',
+ `geaflow.dsl.mongodb.partition.num` = '4',
+ `geaflow.dsl.mongodb.partition.field` = 'id',
+ `geaflow.dsl.mongodb.partition.lowerbound` = '0',
+ `geaflow.dsl.mongodb.partition.upperbound` = '100'
+);
+```
+
+## 参数
+
+| 参数名 | 是否必须 | 默认值 | 描述 |
+| --- | --- | --- | --- |
+| `geaflow.dsl.mongodb.uri` | 是 | - | MongoDB connection string。 |
+| `geaflow.dsl.mongodb.database` | 是 | - | database 名称。 |
+| `geaflow.dsl.mongodb.collection` | 是 | - | collection 名称。 |
+| `geaflow.dsl.mongodb.batch.size` | 否 | `1000` | sink 每批插入的行数。 |
+| `geaflow.dsl.mongodb.partition.num` | 否 | `1` | source 范围分区数上限。 |
+| `geaflow.dsl.mongodb.partition.field` | 分区数大于 1 时 | - | 使用整数边界拆分的数值型字段。 |
+| `geaflow.dsl.mongodb.partition.lowerbound` | 分区数大于 1 时 | - | 范围下界,包含该值。 |
+| `geaflow.dsl.mongodb.partition.upperbound` | 分区数大于 1 时 | - | 范围上界,不包含该值。 |
+
+分区数为 1 时,source 不使用范围条件。分区数大于 1 时,配置范围会拆分为互不重叠的
+`[lowerbound, upperbound)` 查询条件,范围外的文档不会被读取。实际分区数取
+`partition.num` 和 `upperbound - lowerbound` 中的较小值,避免产生空的整数范围。
+
+对于大小窗口,source 使用 bookmark offset 和固定排序。单分区按 `_id` 排序;范围分区按
+分区字段和 `_id` 排序。范围分区读取建议创建对应的升序复合索引,例如
+`{id: 1, _id: 1}`。全量窗口直接流式读取 MongoDB cursor。source 仅支持静态、有界
+collection,读取期间修改数据仍可能造成遗漏或重复。
+
+sink 使用 ordered insert,在达到批量阈值或窗口结束时写入。不支持 upsert 和
+Exactly-once,也不在 MongoDB driver 的重试行为之外增加重试。
+批量失败时,MongoDB 可能已经写入部分文档。
+
+## 类型映射
+
+| GeaFlow 类型 | BSON 类型 |
+| --- | --- |
+| `VARCHAR` | String |
+| `BOOLEAN` | Boolean |
+| `TINYINT`、`SMALLINT`、`INTEGER` | Int32 |
+| `BIGINT` | Int64 |
+| `FLOAT`、`DOUBLE` | Double |
+| `DECIMAL` | Decimal128 |
+| `DATE`、`TIMESTAMP` | Date |
+
+字段缺失和 BSON null 均读取为 null。表结构中的 `_id` 为 `VARCHAR` 时,source 会将
+BSON ObjectId 转为十六进制字符串;sink 写入的字符串 `_id` 保持 BSON string。
+首版不支持数组和嵌套文档。
+
+## 示例
+
+```sql
+CREATE TABLE mongo_source (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'source_records'
+);
+
+CREATE TABLE mongo_sink (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'sink_records',
+ `geaflow.dsl.mongodb.batch.size` = '500'
+);
+
+INSERT INTO mongo_sink
+SELECT id, name, active FROM mongo_source;
+```
diff --git a/docs/docs-cn/source/5.application-development/3.connector/index.rst b/docs/docs-cn/source/5.application-development/3.connector/index.rst
index 2511eee35..5c0a9faa0 100644
--- a/docs/docs-cn/source/5.application-development/3.connector/index.rst
+++ b/docs/docs-cn/source/5.application-development/3.connector/index.rst
@@ -17,4 +17,4 @@
9.pulsar.md
10.udc.md
11.doris.md
-
+ 12.mongodb.md
diff --git a/docs/docs-en/source/5.application-development/3.connector/12.mongodb.md b/docs/docs-en/source/5.application-development/3.connector/12.mongodb.md
new file mode 100644
index 000000000..71c0af71d
--- /dev/null
+++ b/docs/docs-en/source/5.application-development/3.connector/12.mongodb.md
@@ -0,0 +1,96 @@
+# MongoDB Connector
+
+The MongoDB connector reads a bounded collection and inserts rows into a collection.
+
+## Syntax
+
+```sql
+CREATE TABLE mongo_source (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'source_records',
+ `geaflow.dsl.mongodb.partition.num` = '4',
+ `geaflow.dsl.mongodb.partition.field` = 'id',
+ `geaflow.dsl.mongodb.partition.lowerbound` = '0',
+ `geaflow.dsl.mongodb.partition.upperbound` = '100'
+);
+```
+
+## Options
+
+| Key | Required | Default | Description |
+| --- | --- | --- | --- |
+| `geaflow.dsl.mongodb.uri` | Yes | - | MongoDB connection string. |
+| `geaflow.dsl.mongodb.database` | Yes | - | Database name. |
+| `geaflow.dsl.mongodb.collection` | Yes | - | Collection name. |
+| `geaflow.dsl.mongodb.batch.size` | No | `1000` | Number of rows in each sink insert batch. |
+| `geaflow.dsl.mongodb.partition.num` | No | `1` | Maximum number of source range partitions. |
+| `geaflow.dsl.mongodb.partition.field` | When partition number is greater than 1 | - | Numeric field split with integer range bounds. |
+| `geaflow.dsl.mongodb.partition.lowerbound` | For multiple partitions | - | Inclusive range lower bound. |
+| `geaflow.dsl.mongodb.partition.upperbound` | For multiple partitions | - | Exclusive range upper bound. |
+
+With one partition, the source scans the collection without a range filter. With multiple
+partitions, the configured range is split into non-overlapping `[lowerbound, upperbound)`
+filters. Documents outside that range are not read. The actual partition count is the smaller
+of `partition.num` and `upperbound - lowerbound`, which avoids empty integer ranges.
+
+For size windows, the source uses bookmark offsets with a stable sort. A single partition sorts
+by `_id`; range partitions sort by the partition field and `_id`. For efficient range-partitioned
+reads, create an ascending compound index on both fields, for example `{id: 1, _id: 1}`.
+All-window reads stream directly from the MongoDB cursor. The source supports static, bounded
+collections; changes made during a read can cause missing or repeated documents.
+
+The sink uses ordered inserts. It flushes when the batch size is reached and when a window
+finishes. It does not provide upsert, exactly-once delivery, or retries beyond the MongoDB driver
+behavior configured by the connection string. A failed batch can be partially written by MongoDB.
+
+## Type mapping
+
+| GeaFlow type | BSON type |
+| --- | --- |
+| `VARCHAR` | String |
+| `BOOLEAN` | Boolean |
+| `TINYINT`, `SMALLINT`, `INTEGER` | Int32 |
+| `BIGINT` | Int64 |
+| `FLOAT`, `DOUBLE` | Double |
+| `DECIMAL` | Decimal128 |
+| `DATE`, `TIMESTAMP` | Date |
+
+Missing fields and BSON null values are read as null. If `_id` is declared as `VARCHAR`, a BSON
+ObjectId is returned as its hexadecimal string. A string `_id` written by the sink remains a
+BSON string. Arrays and nested documents are not supported.
+
+## Example
+
+```sql
+CREATE TABLE mongo_source (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'source_records'
+);
+
+CREATE TABLE mongo_sink (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'sink_records',
+ `geaflow.dsl.mongodb.batch.size` = '500'
+);
+
+INSERT INTO mongo_sink
+SELECT id, name, active FROM mongo_source;
+```
diff --git a/docs/docs-en/source/5.application-development/3.connector/index.rst b/docs/docs-en/source/5.application-development/3.connector/index.rst
index ddfa8998c..e77b75458 100644
--- a/docs/docs-en/source/5.application-development/3.connector/index.rst
+++ b/docs/docs-en/source/5.application-development/3.connector/index.rst
@@ -16,4 +16,5 @@ Connector
8.hudi.md
9.pulsar.md
10.udc.md
- 11.doris.md
\ No newline at end of file
+ 11.doris.md
+ 12.mongodb.md
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/pom.xml b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/pom.xml
new file mode 100644
index 000000000..7ea2b12a2
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/pom.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+ org.apache.geaflow
+ geaflow-dsl-connector
+ 0.8.0-SNAPSHOT
+
+
+ 4.0.0
+
+ geaflow-dsl-connector-mongodb
+
+
+ 4.11.1
+ 1.19.8
+
+
+
+
+ org.apache.geaflow
+ geaflow-dsl-common
+
+
+ org.apache.geaflow
+ geaflow-dsl-connector-api
+
+
+ org.mongodb
+ mongodb-driver-sync
+ ${mongodb-driver.version}
+
+
+
+ org.testng
+ testng
+ ${testng.version}
+ test
+
+
+ org.testcontainers
+ mongodb
+ ${testcontainers.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+ ${maven-surefire.version}
+
+
+
+ integration-test
+ verify
+
+
+
+
+
+
+
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoConfigKeys.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoConfigKeys.java
new file mode 100644
index 000000000..de8d08ea9
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoConfigKeys.java
@@ -0,0 +1,69 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import org.apache.geaflow.common.config.ConfigKey;
+import org.apache.geaflow.common.config.ConfigKeys;
+
+public class MongoConfigKeys {
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_URI = ConfigKeys
+ .key("geaflow.dsl.mongodb.uri")
+ .noDefaultValue()
+ .description("MongoDB connection string.");
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_DATABASE = ConfigKeys
+ .key("geaflow.dsl.mongodb.database")
+ .noDefaultValue()
+ .description("MongoDB database name.");
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_COLLECTION = ConfigKeys
+ .key("geaflow.dsl.mongodb.collection")
+ .noDefaultValue()
+ .description("MongoDB collection name.");
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_BATCH_SIZE = ConfigKeys
+ .key("geaflow.dsl.mongodb.batch.size")
+ .defaultValue(1000)
+ .description("MongoDB sink batch size.");
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_PARTITION_NUM = ConfigKeys
+ .key("geaflow.dsl.mongodb.partition.num")
+ .defaultValue(1)
+ .description("MongoDB source partition number.");
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_PARTITION_FIELD = ConfigKeys
+ .key("geaflow.dsl.mongodb.partition.field")
+ .noDefaultValue()
+ .description("MongoDB source range partition field.");
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND = ConfigKeys
+ .key("geaflow.dsl.mongodb.partition.lowerbound")
+ .noDefaultValue()
+ .description("Inclusive lower bound for MongoDB source partitions.");
+
+ public static final ConfigKey GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND = ConfigKeys
+ .key("geaflow.dsl.mongodb.partition.upperbound")
+ .noDefaultValue()
+ .description("Exclusive upper bound for MongoDB source partitions.");
+
+ private MongoConfigKeys() {
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoOffset.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoOffset.java
new file mode 100644
index 000000000..64e25743f
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoOffset.java
@@ -0,0 +1,73 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import org.apache.geaflow.dsl.connector.api.Offset;
+import org.bson.BsonDocument;
+import org.bson.json.JsonMode;
+import org.bson.json.JsonWriterSettings;
+
+public class MongoOffset implements Offset {
+
+ static final String ID_FIELD = "_id";
+ static final String PARTITION_VALUE_FIELD = "partitionValue";
+
+ private static final JsonWriterSettings JSON_SETTINGS = JsonWriterSettings.builder()
+ .outputMode(JsonMode.EXTENDED)
+ .build();
+
+ private final long offset;
+ private final String bookmark;
+
+ public MongoOffset(long offset) {
+ this(offset, null);
+ }
+
+ MongoOffset(long offset, BsonDocument bookmark) {
+ if (offset < 0) {
+ throw new IllegalArgumentException("MongoDB offset must not be negative");
+ }
+ this.offset = offset;
+ this.bookmark = bookmark == null ? null : bookmark.toJson(JSON_SETTINGS);
+ }
+
+ boolean hasBookmark() {
+ return bookmark != null;
+ }
+
+ BsonDocument getBookmark() {
+ return bookmark == null ? null : BsonDocument.parse(bookmark);
+ }
+
+ @Override
+ public String humanReadable() {
+ return String.valueOf(offset);
+ }
+
+ @Override
+ public long getOffset() {
+ return offset;
+ }
+
+ @Override
+ public boolean isTimestamp() {
+ return false;
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoPartition.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoPartition.java
new file mode 100644
index 000000000..cf4331acc
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoPartition.java
@@ -0,0 +1,99 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import com.mongodb.client.model.Filters;
+import java.util.Objects;
+import org.apache.geaflow.dsl.connector.api.Partition;
+import org.bson.BsonDocument;
+import org.bson.conversions.Bson;
+
+public class MongoPartition implements Partition {
+
+ private final String collection;
+ private final int index;
+ private final String field;
+ private final Long lowerBound;
+ private final Long upperBound;
+
+ public MongoPartition(String collection, int index) {
+ this(collection, index, null, null, null);
+ }
+
+ public MongoPartition(String collection, int index, String field, Long lowerBound,
+ Long upperBound) {
+ this.collection = Objects.requireNonNull(collection);
+ this.index = index;
+ this.field = field;
+ this.lowerBound = lowerBound;
+ this.upperBound = upperBound;
+ }
+
+ public String getCollection() {
+ return collection;
+ }
+
+ public String getField() {
+ return field;
+ }
+
+ public Long getLowerBound() {
+ return lowerBound;
+ }
+
+ public Long getUpperBound() {
+ return upperBound;
+ }
+
+ public boolean hasRange() {
+ return field != null;
+ }
+
+ public Bson toFilter() {
+ if (!hasRange()) {
+ return new BsonDocument();
+ }
+ return Filters.and(Filters.gte(field, lowerBound), Filters.lt(field, upperBound));
+ }
+
+ @Override
+ public String getName() {
+ return collection + "-" + index;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof MongoPartition)) {
+ return false;
+ }
+ MongoPartition that = (MongoPartition) o;
+ return index == that.index && Objects.equals(collection, that.collection)
+ && Objects.equals(field, that.field) && Objects.equals(lowerBound, that.lowerBound)
+ && Objects.equals(upperBound, that.upperBound);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(collection, index, field, lowerBound, upperBound);
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoRowConverter.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoRowConverter.java
new file mode 100644
index 000000000..4347f8f14
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoRowConverter.java
@@ -0,0 +1,236 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.List;
+import org.apache.geaflow.common.binary.BinaryString;
+import org.apache.geaflow.common.config.Configuration;
+import org.apache.geaflow.common.type.IType;
+import org.apache.geaflow.common.type.Types;
+import org.apache.geaflow.dsl.common.data.Row;
+import org.apache.geaflow.dsl.common.data.impl.ObjectRow;
+import org.apache.geaflow.dsl.common.exception.GeaFlowDSLException;
+import org.apache.geaflow.dsl.common.types.StructType;
+import org.apache.geaflow.dsl.common.types.TableField;
+import org.apache.geaflow.dsl.connector.api.serde.TableDeserializer;
+import org.bson.Document;
+import org.bson.types.Decimal128;
+import org.bson.types.ObjectId;
+
+class MongoRowConverter implements TableDeserializer {
+
+ private StructType schema;
+
+ MongoRowConverter(StructType schema) {
+ this.schema = schema;
+ }
+
+ @Override
+ public void init(Configuration conf, StructType structType) {
+ this.schema = structType;
+ }
+
+ @Override
+ public List deserialize(Document document) {
+ return Collections.singletonList(toRow(document));
+ }
+
+ Row toRow(Document document) {
+ Object[] values = new Object[schema.size()];
+ for (int i = 0; i < schema.size(); i++) {
+ TableField field = schema.getField(i);
+ values[i] = fromBson(field.getName(), field.getType(), document.get(field.getName()));
+ }
+ return ObjectRow.create(values);
+ }
+
+ Document toDocument(Row row) {
+ Document document = new Document();
+ for (int i = 0; i < schema.size(); i++) {
+ TableField field = schema.getField(i);
+ Object value = row.getField(i, field.getType());
+ document.put(field.getName(), toBson(field.getName(), field.getType(), value));
+ }
+ return document;
+ }
+
+ private Object fromBson(String field, IType> type, Object value) {
+ if (value == null) {
+ return null;
+ }
+ switch (type.getName()) {
+ case Types.TYPE_NAME_STRING:
+ return asString(field, type, value);
+ case Types.TYPE_NAME_BINARY_STRING:
+ return BinaryString.fromString(asString(field, type, value));
+ case Types.TYPE_NAME_BOOLEAN:
+ return requireType(field, type, value, Boolean.class);
+ case Types.TYPE_NAME_BYTE:
+ return (byte) integralValue(field, type, value, Byte.MIN_VALUE, Byte.MAX_VALUE);
+ case Types.TYPE_NAME_SHORT:
+ return (short) integralValue(field, type, value, Short.MIN_VALUE, Short.MAX_VALUE);
+ case Types.TYPE_NAME_INTEGER:
+ return (int) integralValue(field, type, value, Integer.MIN_VALUE, Integer.MAX_VALUE);
+ case Types.TYPE_NAME_LONG:
+ return integralValue(field, type, value, Long.MIN_VALUE, Long.MAX_VALUE);
+ case Types.TYPE_NAME_FLOAT:
+ return floatValue(field, type, value);
+ case Types.TYPE_NAME_DOUBLE:
+ return decimalValue(field, type, value);
+ case Types.TYPE_NAME_DECIMAL:
+ return decimal128Value(field, type, value).bigDecimalValue();
+ case Types.TYPE_NAME_TIMESTAMP:
+ return new Timestamp(dateValue(field, type, value).getTime());
+ case Types.TYPE_NAME_DATE:
+ return new java.sql.Date(dateValue(field, type, value).getTime());
+ default:
+ throw conversionError(field, type, value);
+ }
+ }
+
+ private Object toBson(String field, IType> type, Object value) {
+ if (value == null) {
+ return null;
+ }
+ switch (type.getName()) {
+ case Types.TYPE_NAME_STRING:
+ return requireType(field, type, value, String.class);
+ case Types.TYPE_NAME_BINARY_STRING:
+ return requireType(field, type, value, BinaryString.class).toString();
+ case Types.TYPE_NAME_BOOLEAN:
+ return requireType(field, type, value, Boolean.class);
+ case Types.TYPE_NAME_BYTE:
+ return (int) requireType(field, type, value, Byte.class);
+ case Types.TYPE_NAME_SHORT:
+ return (int) requireType(field, type, value, Short.class);
+ case Types.TYPE_NAME_INTEGER:
+ return requireType(field, type, value, Integer.class);
+ case Types.TYPE_NAME_LONG:
+ return requireType(field, type, value, Long.class);
+ case Types.TYPE_NAME_FLOAT:
+ return (double) requireType(field, type, value, Float.class);
+ case Types.TYPE_NAME_DOUBLE:
+ return requireType(field, type, value, Double.class);
+ case Types.TYPE_NAME_DECIMAL:
+ return new Decimal128(requireType(field, type, value, BigDecimal.class));
+ case Types.TYPE_NAME_TIMESTAMP:
+ Timestamp timestamp = requireType(field, type, value, Timestamp.class);
+ return new java.util.Date(timestamp.getTime());
+ case Types.TYPE_NAME_DATE:
+ java.sql.Date date = requireType(field, type, value, java.sql.Date.class);
+ return new java.util.Date(date.getTime());
+ default:
+ throw conversionError(field, type, value);
+ }
+ }
+
+ private String asString(String field, IType> type, Object value) {
+ if (value instanceof String) {
+ return (String) value;
+ }
+ if ("_id".equals(field) && value instanceof ObjectId) {
+ return ((ObjectId) value).toHexString();
+ }
+ throw conversionError(field, type, value);
+ }
+
+ private long integralValue(String field, IType> type, Object value, long min, long max) {
+ if (!(value instanceof Number)) {
+ throw conversionError(field, type, value);
+ }
+ Number number = (Number) value;
+ try {
+ BigDecimal decimal;
+ if (number instanceof Decimal128) {
+ decimal = ((Decimal128) number).bigDecimalValue();
+ } else if (number instanceof BigDecimal) {
+ decimal = (BigDecimal) number;
+ } else if (number instanceof Byte || number instanceof Short
+ || number instanceof Integer || number instanceof Long) {
+ decimal = BigDecimal.valueOf(number.longValue());
+ } else {
+ double doubleValue = number.doubleValue();
+ if (!Double.isFinite(doubleValue)) {
+ throw conversionError(field, type, value);
+ }
+ decimal = BigDecimal.valueOf(doubleValue);
+ }
+ long longValue = decimal.longValueExact();
+ if (longValue < min || longValue > max) {
+ throw conversionError(field, type, value);
+ }
+ return longValue;
+ } catch (ArithmeticException e) {
+ throw conversionError(field, type, value);
+ }
+ }
+
+ private float floatValue(String field, IType> type, Object value) {
+ double result = decimalValue(field, type, value);
+ float floatResult = (float) result;
+ if (!Float.isFinite(floatResult)) {
+ throw conversionError(field, type, value);
+ }
+ return floatResult;
+ }
+
+ private double decimalValue(String field, IType> type, Object value) {
+ if (!(value instanceof Number)) {
+ throw conversionError(field, type, value);
+ }
+ double result = ((Number) value).doubleValue();
+ if (!Double.isFinite(result)) {
+ throw conversionError(field, type, value);
+ }
+ return result;
+ }
+
+ private Decimal128 decimal128Value(String field, IType> type, Object value) {
+ if (value instanceof Decimal128) {
+ return (Decimal128) value;
+ }
+ if (value instanceof BigDecimal) {
+ return new Decimal128((BigDecimal) value);
+ }
+ throw conversionError(field, type, value);
+ }
+
+ private java.util.Date dateValue(String field, IType> type, Object value) {
+ if (!(value instanceof java.util.Date)) {
+ throw conversionError(field, type, value);
+ }
+ return (java.util.Date) value;
+ }
+
+ private T requireType(String field, IType> type, Object value, Class valueClass) {
+ if (!valueClass.isInstance(value)) {
+ throw conversionError(field, type, value);
+ }
+ return valueClass.cast(value);
+ }
+
+ private GeaFlowDSLException conversionError(String field, IType> type, Object value) {
+ return new GeaFlowDSLException("Cannot map MongoDB field '{}' from {} to {}", field,
+ value.getClass().getSimpleName(), type.getName());
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnector.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnector.java
new file mode 100644
index 000000000..c60d27ee5
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnector.java
@@ -0,0 +1,46 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import org.apache.geaflow.common.config.Configuration;
+import org.apache.geaflow.dsl.connector.api.TableReadableConnector;
+import org.apache.geaflow.dsl.connector.api.TableSink;
+import org.apache.geaflow.dsl.connector.api.TableSource;
+import org.apache.geaflow.dsl.connector.api.TableWritableConnector;
+
+public class MongoTableConnector implements TableReadableConnector, TableWritableConnector {
+
+ public static final String TYPE = "MONGODB";
+
+ @Override
+ public String getType() {
+ return TYPE;
+ }
+
+ @Override
+ public TableSource createSource(Configuration conf) {
+ return new MongoTableSource();
+ }
+
+ @Override
+ public TableSink createSink(Configuration conf) {
+ return new MongoTableSink();
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableSink.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableSink.java
new file mode 100644
index 000000000..694670f6c
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableSink.java
@@ -0,0 +1,131 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import com.mongodb.MongoBulkWriteException;
+import com.mongodb.MongoException;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.model.InsertManyOptions;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.geaflow.api.context.RuntimeContext;
+import org.apache.geaflow.common.config.ConfigKey;
+import org.apache.geaflow.common.config.Configuration;
+import org.apache.geaflow.dsl.common.data.Row;
+import org.apache.geaflow.dsl.common.exception.GeaFlowDSLException;
+import org.apache.geaflow.dsl.common.types.StructType;
+import org.apache.geaflow.dsl.connector.api.TableSink;
+import org.bson.Document;
+
+public class MongoTableSink implements TableSink {
+
+ private String uri;
+ private String databaseName;
+ private String collectionName;
+ private int batchSize;
+ private MongoRowConverter converter;
+ private List batch;
+
+ private transient MongoClient client;
+ private transient MongoCollection collection;
+
+ @Override
+ public void init(Configuration tableConf, StructType schema) {
+ this.uri = required(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_URI);
+ this.databaseName = required(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_DATABASE);
+ this.collectionName = required(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_COLLECTION);
+ this.batchSize = tableConf.getInteger(MongoConfigKeys.GEAFLOW_DSL_MONGODB_BATCH_SIZE);
+ if (batchSize <= 0) {
+ throw new GeaFlowDSLException("MongoDB batch size must be greater than zero");
+ }
+ this.converter = new MongoRowConverter(schema);
+ this.batch = new ArrayList<>(batchSize);
+ }
+
+ @Override
+ public void open(RuntimeContext context) {
+ try {
+ client = MongoClients.create(uri);
+ collection = client.getDatabase(databaseName).getCollection(collectionName);
+ } catch (MongoException | IllegalArgumentException e) {
+ close();
+ throw new GeaFlowDSLException("Failed to create MongoDB sink client", e);
+ }
+ }
+
+ @Override
+ public void write(Row row) throws IOException {
+ if (collection == null) {
+ throw new GeaFlowDSLException("MongoDB sink is not open");
+ }
+ batch.add(converter.toDocument(row));
+ if (batch.size() >= batchSize) {
+ flush();
+ }
+ }
+
+ @Override
+ public void finish() throws IOException {
+ flush();
+ }
+
+ @Override
+ public void close() {
+ if (client != null) {
+ client.close();
+ client = null;
+ collection = null;
+ }
+ }
+
+ private void flush() throws IOException {
+ if (batch.isEmpty()) {
+ return;
+ }
+ try {
+ collection.insertMany(batch, new InsertManyOptions().ordered(true));
+ batch.clear();
+ } catch (MongoBulkWriteException e) {
+ String writeConcernError = e.getWriteConcernError() == null ? ""
+ : "; write concern error: " + e.getWriteConcernError();
+ throw new IOException("MongoDB bulk write failed for collection " + collectionName
+ + "; successful inserts in this batch: "
+ + e.getWriteResult().getInsertedCount() + "; write errors: "
+ + e.getWriteErrors() + writeConcernError, e);
+ } catch (MongoException e) {
+ throw new IOException("Failed to write MongoDB collection " + collectionName, e);
+ }
+ }
+
+ private static String required(Configuration conf, ConfigKey key) {
+ if (!conf.contains(key)) {
+ throw new GeaFlowDSLException("Missing MongoDB configuration '{}'", key.getKey());
+ }
+ String value = conf.getString(key);
+ if (value == null || value.trim().isEmpty()) {
+ throw new GeaFlowDSLException("MongoDB configuration '{}' must not be blank",
+ key.getKey());
+ }
+ return value;
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableSource.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableSource.java
new file mode 100644
index 000000000..de77d702f
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableSource.java
@@ -0,0 +1,378 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import com.mongodb.MongoException;
+import com.mongodb.client.FindIterable;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoCursor;
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.model.Sorts;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import org.apache.geaflow.api.context.RuntimeContext;
+import org.apache.geaflow.api.window.WindowType;
+import org.apache.geaflow.common.config.ConfigKey;
+import org.apache.geaflow.common.config.Configuration;
+import org.apache.geaflow.dsl.common.exception.GeaFlowDSLException;
+import org.apache.geaflow.dsl.common.types.TableSchema;
+import org.apache.geaflow.dsl.connector.api.FetchData;
+import org.apache.geaflow.dsl.connector.api.Offset;
+import org.apache.geaflow.dsl.connector.api.Partition;
+import org.apache.geaflow.dsl.connector.api.TableSource;
+import org.apache.geaflow.dsl.connector.api.serde.TableDeserializer;
+import org.apache.geaflow.dsl.connector.api.window.FetchWindow;
+import org.bson.BsonDocument;
+import org.bson.BsonValue;
+import org.bson.Document;
+import org.bson.conversions.Bson;
+
+public class MongoTableSource implements TableSource {
+
+ private String uri;
+ private String databaseName;
+ private String collectionName;
+ private int partitionNum;
+ private String partitionField;
+ private long lowerBound;
+ private long upperBound;
+ private TableSchema schema;
+
+ private transient MongoClient client;
+ private transient MongoCollection collection;
+
+ @Override
+ public void init(Configuration tableConf, TableSchema tableSchema) {
+ this.uri = required(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_URI);
+ this.databaseName = required(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_DATABASE);
+ this.collectionName = required(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_COLLECTION);
+ this.partitionNum = tableConf.getInteger(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM);
+ this.schema = tableSchema;
+
+ if (partitionNum <= 0) {
+ throw new GeaFlowDSLException("MongoDB partition number must be greater than zero");
+ }
+ if (partitionNum > 1) {
+ this.partitionField = required(tableConf,
+ MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_FIELD);
+ requireConfig(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND);
+ requireConfig(tableConf, MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND);
+ this.lowerBound = tableConf.getLong(
+ MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND);
+ this.upperBound = tableConf.getLong(
+ MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND);
+ if (lowerBound >= upperBound) {
+ throw new GeaFlowDSLException(
+ "MongoDB partition upper bound must be greater than lower bound");
+ }
+ }
+ }
+
+ @Override
+ public void open(RuntimeContext context) {
+ try {
+ client = MongoClients.create(uri);
+ collection = client.getDatabase(databaseName).getCollection(collectionName);
+ } catch (MongoException | IllegalArgumentException e) {
+ close();
+ throw new GeaFlowDSLException("Failed to create MongoDB source client", e);
+ }
+ }
+
+ @Override
+ public List listPartitions() {
+ if (partitionNum == 1) {
+ return Collections.singletonList(new MongoPartition(collectionName, 0));
+ }
+
+ BigInteger lower = BigInteger.valueOf(lowerBound);
+ BigInteger range = BigInteger.valueOf(upperBound).subtract(lower);
+ int count = range.min(BigInteger.valueOf(partitionNum)).intValueExact();
+ BigInteger[] strideAndRemainder = range.divideAndRemainder(BigInteger.valueOf(count));
+ BigInteger current = lower;
+ List partitions = new ArrayList<>(count);
+ for (int i = 0; i < count; i++) {
+ BigInteger increment = strideAndRemainder[0];
+ if (i < strideAndRemainder[1].intValue()) {
+ increment = increment.add(BigInteger.ONE);
+ }
+ BigInteger next = current.add(increment);
+ partitions.add(new MongoPartition(collectionName, i, partitionField,
+ current.longValueExact(), next.longValueExact()));
+ current = next;
+ }
+ return partitions;
+ }
+
+ @Override
+ public List listPartitions(int parallelism) {
+ return listPartitions();
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public TableDeserializer getDeserializer(Configuration conf) {
+ return (TableDeserializer) new MongoRowConverter(schema);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public FetchData fetch(Partition partition, Optional startOffset,
+ FetchWindow windowInfo) throws IOException {
+ if (collection == null) {
+ throw new GeaFlowDSLException("MongoDB source is not open");
+ }
+ if (!(partition instanceof MongoPartition)) {
+ throw new GeaFlowDSLException("Invalid MongoDB partition");
+ }
+ MongoPartition mongoPartition = (MongoPartition) partition;
+ if (!collectionName.equals(mongoPartition.getCollection())) {
+ throw new GeaFlowDSLException("MongoDB partition belongs to another collection");
+ }
+ if (windowInfo.getType() != WindowType.SIZE_TUMBLING_WINDOW
+ && windowInfo.getType() != WindowType.ALL_WINDOW) {
+ throw new GeaFlowDSLException("Unsupported MongoDB fetch window: {}",
+ windowInfo.getType());
+ }
+
+ MongoOffset offset = getMongoOffset(startOffset);
+
+ boolean allWindow = windowInfo.getType() == WindowType.ALL_WINDOW;
+ int limit = 0;
+ if (!allWindow) {
+ long windowSize = windowInfo.windowSize();
+ if (windowSize <= 0 || windowSize > Integer.MAX_VALUE) {
+ throw new GeaFlowDSLException("MongoDB fetch window size is out of range: {}",
+ windowSize);
+ }
+ limit = (int) windowSize;
+ }
+
+ Bson filter = createFilter(mongoPartition, offset);
+ if (allWindow) {
+ try {
+ MongoCursor cursor = collection.find(filter).iterator();
+ return (FetchData) FetchData.createBatchFetch(
+ new MongoCursorIterator(cursor, collectionName), offset);
+ } catch (MongoException e) {
+ throw new IOException("Failed to read MongoDB collection " + collectionName, e);
+ }
+ }
+
+ List documents = new ArrayList<>();
+ try {
+ FindIterable query = collection.find(filter)
+ .sort(mongoPartition.hasRange()
+ ? Sorts.ascending(mongoPartition.getField(), "_id") : Sorts.ascending("_id"))
+ .limit(limit);
+ try (MongoCursor cursor = query.iterator()) {
+ while (cursor.hasNext()) {
+ documents.add(cursor.next());
+ }
+ }
+ } catch (MongoException e) {
+ throw new IOException("Failed to read MongoDB collection " + collectionName, e);
+ }
+
+ MongoOffset nextOffset = createNextOffset(offset, mongoPartition, documents);
+ boolean finished = documents.size() < limit;
+ return (FetchData) FetchData.createStreamFetch(documents, nextOffset, finished);
+ }
+
+ static Bson createFilter(MongoPartition partition, MongoOffset offset) {
+ if (!offset.hasBookmark()) {
+ return partition.toFilter();
+ }
+
+ Bson bookmarkFilter = createBookmarkFilter(partition, offset.getBookmark());
+ return partition.hasRange()
+ ? Filters.and(partition.toFilter(), bookmarkFilter) : bookmarkFilter;
+ }
+
+ static Bson createBookmarkFilter(MongoPartition partition, BsonDocument bookmark) {
+ BsonValue lastId = requiredBookmarkValue(bookmark, MongoOffset.ID_FIELD);
+ if (!partition.hasRange()) {
+ return Filters.gt(MongoOffset.ID_FIELD, lastId);
+ }
+
+ BsonValue lastPartitionValue = requiredBookmarkValue(bookmark,
+ MongoOffset.PARTITION_VALUE_FIELD);
+ return Filters.or(
+ Filters.gt(partition.getField(), lastPartitionValue),
+ Filters.and(
+ Filters.eq(partition.getField(), lastPartitionValue),
+ Filters.gt(MongoOffset.ID_FIELD, lastId)));
+ }
+
+ private static BsonValue requiredBookmarkValue(BsonDocument bookmark, String field) {
+ BsonValue value = bookmark.get(field);
+ if (value == null) {
+ throw new GeaFlowDSLException("MongoDB offset is missing bookmark field '{}'",
+ field);
+ }
+ return value;
+ }
+
+ private MongoOffset createNextOffset(MongoOffset offset, MongoPartition partition,
+ List documents) {
+ if (documents.isEmpty()) {
+ return offset;
+ }
+
+ Document lastDocument = documents.get(documents.size() - 1);
+ BsonDocument bsonDocument = lastDocument.toBsonDocument(Document.class,
+ collection.getCodecRegistry());
+ BsonValue lastId = getDocumentValue(bsonDocument, MongoOffset.ID_FIELD);
+ if (lastId == null) {
+ throw new GeaFlowDSLException("MongoDB document is missing field '{}'",
+ MongoOffset.ID_FIELD);
+ }
+
+ BsonDocument bookmark = new BsonDocument(MongoOffset.ID_FIELD, lastId);
+ if (partition.hasRange()) {
+ BsonValue partitionValue = getDocumentValue(bsonDocument, partition.getField());
+ if (partitionValue == null) {
+ throw new GeaFlowDSLException("MongoDB document is missing partition field '{}'",
+ partition.getField());
+ }
+ bookmark.append(MongoOffset.PARTITION_VALUE_FIELD, partitionValue);
+ }
+
+ long nextOffset;
+ try {
+ nextOffset = Math.addExact(offset.getOffset(), documents.size());
+ } catch (ArithmeticException e) {
+ throw new GeaFlowDSLException("MongoDB offset exceeds the supported range", e);
+ }
+ return new MongoOffset(nextOffset, bookmark);
+ }
+
+ private static BsonValue getDocumentValue(BsonDocument document, String field) {
+ BsonValue value = document;
+ for (String name : field.split("\\.")) {
+ if (!value.isDocument()) {
+ return null;
+ }
+ value = value.asDocument().get(name);
+ if (value == null) {
+ return null;
+ }
+ }
+ return value;
+ }
+
+ private static MongoOffset getMongoOffset(Optional startOffset) {
+ if (!startOffset.isPresent()) {
+ return new MongoOffset(0L);
+ }
+ Offset offset = startOffset.get();
+ if (!(offset instanceof MongoOffset)) {
+ throw new GeaFlowDSLException("Invalid MongoDB offset");
+ }
+ MongoOffset mongoOffset = (MongoOffset) offset;
+ if (mongoOffset.getOffset() > 0 && !mongoOffset.hasBookmark()) {
+ throw new GeaFlowDSLException("MongoDB offset is missing a bookmark");
+ }
+ return mongoOffset;
+ }
+
+ @Override
+ public void close() {
+ if (client != null) {
+ client.close();
+ client = null;
+ collection = null;
+ }
+ }
+
+ private static String required(Configuration conf, ConfigKey key) {
+ requireConfig(conf, key);
+ String value = conf.getString(key);
+ if (value == null || value.trim().isEmpty()) {
+ throw new GeaFlowDSLException("MongoDB configuration '{}' must not be blank",
+ key.getKey());
+ }
+ return value;
+ }
+
+ private static void requireConfig(Configuration conf, ConfigKey key) {
+ if (!conf.contains(key)) {
+ throw new GeaFlowDSLException("Missing MongoDB configuration '{}'", key.getKey());
+ }
+ }
+
+ private static class MongoCursorIterator implements Iterator {
+
+ private final MongoCursor cursor;
+ private final String collectionName;
+ private boolean closed;
+
+ private MongoCursorIterator(MongoCursor cursor, String collectionName) {
+ this.cursor = cursor;
+ this.collectionName = collectionName;
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (closed) {
+ return false;
+ }
+ try {
+ boolean hasNext = cursor.hasNext();
+ if (!hasNext) {
+ close();
+ }
+ return hasNext;
+ } catch (MongoException e) {
+ close();
+ throw readException(e);
+ }
+ }
+
+ @Override
+ public Document next() {
+ try {
+ return cursor.next();
+ } catch (MongoException e) {
+ close();
+ throw readException(e);
+ }
+ }
+
+ private void close() {
+ if (!closed) {
+ closed = true;
+ cursor.close();
+ }
+ }
+
+ private GeaFlowDSLException readException(MongoException cause) {
+ return new GeaFlowDSLException(
+ "Failed to read MongoDB collection " + collectionName, cause);
+ }
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/resources/META-INF/services/org.apache.geaflow.dsl.connector.api.TableConnector b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/resources/META-INF/services/org.apache.geaflow.dsl.connector.api.TableConnector
new file mode 100644
index 000000000..6dc59cf73
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/main/resources/META-INF/services/org.apache.geaflow.dsl.connector.api.TableConnector
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+org.apache.geaflow.dsl.connector.mongodb.MongoTableConnector
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoRowConverterTest.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoRowConverterTest.java
new file mode 100644
index 000000000..c0e127bd6
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoRowConverterTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.sql.Timestamp;
+import org.apache.geaflow.common.binary.BinaryString;
+import org.apache.geaflow.common.type.Types;
+import org.apache.geaflow.dsl.common.data.Row;
+import org.apache.geaflow.dsl.common.data.impl.ObjectRow;
+import org.apache.geaflow.dsl.common.exception.GeaFlowDSLException;
+import org.apache.geaflow.dsl.common.types.StructType;
+import org.apache.geaflow.dsl.common.types.TableField;
+import org.bson.Document;
+import org.bson.types.Decimal128;
+import org.bson.types.ObjectId;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+public class MongoRowConverterTest {
+
+ private static final StructType SCHEMA = new StructType(
+ new TableField("_id", Types.BINARY_STRING),
+ new TableField("name", Types.BINARY_STRING),
+ new TableField("count", Types.INTEGER),
+ new TableField("total", Types.LONG),
+ new TableField("score", Types.DOUBLE),
+ new TableField("active", Types.BOOLEAN),
+ new TableField("price", Types.DECIMAL),
+ new TableField("created", Types.TIMESTAMP),
+ new TableField("day", Types.DATE));
+
+ @Test
+ public void testDocumentToRow() {
+ ObjectId id = new ObjectId();
+ java.util.Date time = new java.util.Date(1710000000000L);
+ Document document = new Document("_id", id)
+ .append("name", "alice")
+ .append("count", 3)
+ .append("total", 8L)
+ .append("score", 1.5D)
+ .append("active", true)
+ .append("price", new Decimal128(new BigDecimal("12.30")))
+ .append("created", time)
+ .append("day", time);
+
+ Row row = new MongoRowConverter(SCHEMA).toRow(document);
+
+ Assert.assertEquals(row.getField(0, Types.BINARY_STRING),
+ BinaryString.fromString(id.toHexString()));
+ Assert.assertEquals(row.getField(1, Types.BINARY_STRING),
+ BinaryString.fromString("alice"));
+ Assert.assertEquals(row.getField(2, Types.INTEGER), 3);
+ Assert.assertEquals(row.getField(3, Types.LONG), 8L);
+ Assert.assertEquals(row.getField(4, Types.DOUBLE), 1.5D);
+ Assert.assertEquals(row.getField(5, Types.BOOLEAN), true);
+ Assert.assertEquals(row.getField(6, Types.DECIMAL), new BigDecimal("12.30"));
+ Assert.assertEquals(((Timestamp) row.getField(7, Types.TIMESTAMP)).getTime(), time.getTime());
+ Assert.assertEquals(((Date) row.getField(8, Types.DATE)).getTime(), time.getTime());
+ }
+
+ @Test
+ public void testRowToDocument() {
+ Timestamp timestamp = new Timestamp(1710000000000L);
+ Date date = new Date(1710028800000L);
+ Row row = ObjectRow.create(
+ BinaryString.fromString("record-1"),
+ BinaryString.fromString("alice"),
+ 3,
+ 8L,
+ 1.5D,
+ true,
+ new BigDecimal("12.30"),
+ timestamp,
+ date);
+
+ Document document = new MongoRowConverter(SCHEMA).toDocument(row);
+
+ Assert.assertEquals(document.getString("_id"), "record-1");
+ Assert.assertEquals(document.getString("name"), "alice");
+ Assert.assertEquals(document.getInteger("count"), Integer.valueOf(3));
+ Assert.assertEquals(document.getLong("total"), Long.valueOf(8));
+ Assert.assertEquals(document.getDouble("score"), Double.valueOf(1.5D));
+ Assert.assertEquals(document.getBoolean("active"), Boolean.TRUE);
+ Assert.assertEquals(document.get("price"), new Decimal128(new BigDecimal("12.30")));
+ Assert.assertEquals(document.getDate("created").getTime(), timestamp.getTime());
+ Assert.assertEquals(document.getDate("day").getTime(), date.getTime());
+ }
+
+ @Test
+ public void testExactDecimalToLong() {
+ StructType schema = new StructType(new TableField("value", Types.LONG));
+ Document document = new Document("value",
+ new Decimal128(new BigDecimal("9007199254740992")));
+
+ Row row = new MongoRowConverter(schema).toRow(document);
+
+ Assert.assertEquals(row.getField(0, Types.LONG), 9007199254740992L);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*value.*Double.*FLOAT.*")
+ public void testRejectFloatOverflow() {
+ StructType schema = new StructType(new TableField("value", Types.FLOAT));
+ Document document = new Document("value", Double.MAX_VALUE);
+
+ new MongoRowConverter(schema).toRow(document);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*value.*Decimal128.*LONG.*")
+ public void testRejectFractionalDecimalAsLong() {
+ StructType schema = new StructType(new TableField("value", Types.LONG));
+ Document document = new Document("value",
+ new Decimal128(new BigDecimal("9007199254740992.1")));
+
+ new MongoRowConverter(schema).toRow(document);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*name.*Document.*BINARY_STRING.*")
+ public void testRejectNestedDocument() {
+ StructType schema = new StructType(new TableField("name", Types.BINARY_STRING));
+ Document document = new Document("name", new Document("first", "alice"));
+
+ new MongoRowConverter(schema).toRow(document);
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnectorIT.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnectorIT.java
new file mode 100644
index 000000000..30f295486
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnectorIT.java
@@ -0,0 +1,298 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.apache.geaflow.common.binary.BinaryString;
+import org.apache.geaflow.common.config.Configuration;
+import org.apache.geaflow.common.type.Types;
+import org.apache.geaflow.dsl.common.data.Row;
+import org.apache.geaflow.dsl.common.data.impl.ObjectRow;
+import org.apache.geaflow.dsl.common.types.TableField;
+import org.apache.geaflow.dsl.common.types.TableSchema;
+import org.apache.geaflow.dsl.connector.api.FetchData;
+import org.apache.geaflow.dsl.connector.api.Offset;
+import org.apache.geaflow.dsl.connector.api.Partition;
+import org.apache.geaflow.dsl.connector.api.serde.TableDeserializer;
+import org.apache.geaflow.dsl.connector.api.window.AllFetchWindow;
+import org.apache.geaflow.dsl.connector.api.window.SizeFetchWindow;
+import org.bson.Document;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.MongoDBContainer;
+import org.testcontainers.utility.DockerImageName;
+import org.testng.Assert;
+import org.testng.SkipException;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+public class MongoTableConnectorIT {
+
+ private static final String DATABASE = "geaflow";
+ private static final String COLLECTION = "records";
+ private static final TableSchema SCHEMA = new TableSchema(
+ new TableField("id", Types.INTEGER, false),
+ new TableField("name", Types.BINARY_STRING),
+ new TableField("active", Types.BOOLEAN));
+ private static final TableSchema BOOKMARK_SCHEMA = new TableSchema(
+ new TableField("_id", Types.BINARY_STRING, false),
+ new TableField("group_id", Types.INTEGER, false));
+
+ private MongoDBContainer container;
+
+ @BeforeClass
+ public void startMongoDB() {
+ boolean dockerAvailable;
+ try {
+ dockerAvailable = DockerClientFactory.instance().isDockerAvailable();
+ } catch (RuntimeException e) {
+ throw new SkipException("Docker is not available", e);
+ }
+ if (!dockerAvailable) {
+ throw new SkipException("Docker is not available");
+ }
+ container = new MongoDBContainer(DockerImageName.parse("mongo:6.0.14"));
+ container.start();
+ }
+
+ @AfterClass(alwaysRun = true)
+ public void stopMongoDB() {
+ if (container != null) {
+ container.stop();
+ }
+ }
+
+ @BeforeMethod
+ public void clearCollection() {
+ try (MongoClient client = MongoClients.create(container.getReplicaSetUrl())) {
+ client.getDatabase(DATABASE).getCollection(COLLECTION).deleteMany(new Document());
+ }
+ }
+
+ @Test
+ public void testBatchWriteAndPartitionedRead() throws IOException {
+ Configuration sinkConf = baseConfig();
+ sinkConf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_BATCH_SIZE, "2");
+ MongoTableSink sink = new MongoTableSink();
+ sink.init(sinkConf, SCHEMA);
+ sink.open(null);
+
+ sink.write(row(1, "alice"));
+ sink.write(row(2, "bob"));
+ Assert.assertEquals(countDocuments(), 2L);
+ sink.write(row(3, "carol"));
+ Assert.assertEquals(countDocuments(), 2L);
+ sink.finish();
+ sink.close();
+ Assert.assertEquals(countDocuments(), 3L);
+
+ Configuration sourceConf = baseConfig();
+ sourceConf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM, "2");
+ sourceConf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_FIELD, "id");
+ sourceConf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND, "1");
+ sourceConf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND, "4");
+ MongoTableSource source = new MongoTableSource();
+ source.init(sourceConf, SCHEMA);
+ source.open(null);
+
+ Set ids = new HashSet<>();
+ List partitions = source.listPartitions();
+ for (Partition partition : partitions) {
+ readPartition(source, sourceConf, partition, ids);
+ }
+ source.close();
+
+ Assert.assertEquals(ids, new HashSet<>(Arrays.asList(1, 2, 3)));
+ }
+
+ @Test
+ public void testCompositeBookmarkPagination() throws IOException {
+ insertDocuments(Arrays.asList(
+ new Document("_id", "z").append("group_id", 1),
+ new Document("_id", "a").append("group_id", 2),
+ new Document("_id", "y").append("group_id", 6),
+ new Document("_id", "b").append("group_id", 7)));
+
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM, "2");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_FIELD, "group_id");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND, "0");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND, "10");
+ MongoTableSource source = new MongoTableSource();
+ source.init(conf, BOOKMARK_SCHEMA);
+ source.open(null);
+
+ Set ids = new HashSet<>();
+ try {
+ for (Partition partition : source.listPartitions()) {
+ readStringPartition(source, conf, partition, ids);
+ }
+ } finally {
+ source.close();
+ }
+
+ Assert.assertEquals(ids, new HashSet<>(Arrays.asList("z", "a", "y", "b")));
+ }
+
+ @Test
+ public void testAllWindowRead() throws IOException {
+ insertDocuments(Arrays.asList(
+ new Document("id", 1),
+ new Document("id", 2),
+ new Document("id", 3)));
+
+ Configuration conf = baseConfig();
+ MongoTableSource source = new MongoTableSource();
+ source.init(conf, SCHEMA);
+ source.open(null);
+
+ try {
+ Partition partition = source.listPartitions().get(0);
+ FetchData fetchData = source.fetch(partition, Optional.empty(),
+ new AllFetchWindow(0));
+ Assert.assertEquals(fetchData.getDataSize(), -1);
+ Assert.assertTrue(fetchData.isFinish());
+
+ int count = 0;
+ Iterator iterator = fetchData.getDataIterator();
+ while (iterator.hasNext()) {
+ iterator.next();
+ count++;
+ }
+ Assert.assertEquals(count, 3);
+ } finally {
+ source.close();
+ }
+ }
+
+ @Test
+ public void testOrderedBulkWriteFailure() throws IOException {
+ insertDocuments(Arrays.asList(
+ new Document("_id", "duplicate").append("group_id", 0)));
+
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_BATCH_SIZE, "3");
+ MongoTableSink sink = new MongoTableSink();
+ sink.init(conf, BOOKMARK_SCHEMA);
+ sink.open(null);
+
+ IOException failure = null;
+ try {
+ sink.write(bookmarkRow("first", 1));
+ sink.write(bookmarkRow("duplicate", 2));
+ try {
+ sink.write(bookmarkRow("last", 3));
+ } catch (IOException e) {
+ failure = e;
+ }
+ } finally {
+ sink.close();
+ }
+
+ Assert.assertNotNull(failure);
+ Assert.assertTrue(failure.getMessage().contains(
+ "successful inserts in this batch: 1"));
+ Assert.assertTrue(failure.getMessage().contains("write errors:"));
+ Assert.assertEquals(countDocuments(), 2L);
+ Assert.assertEquals(countDocuments(new Document("_id", "last")), 0L);
+ }
+
+ private void readPartition(MongoTableSource source, Configuration conf, Partition partition,
+ Set ids) throws IOException {
+ Optional offset = Optional.empty();
+ boolean finished;
+ TableDeserializer deserializer = source.getDeserializer(conf);
+ deserializer.init(conf, SCHEMA);
+ do {
+ FetchData fetchData = source.fetch(partition, offset,
+ new SizeFetchWindow(0, 1));
+ Iterator iterator = fetchData.getDataIterator();
+ while (iterator.hasNext()) {
+ Row row = deserializer.deserialize(iterator.next()).get(0);
+ Integer id = (Integer) row.getField(0, Types.INTEGER);
+ Assert.assertTrue(ids.add(id), "Duplicate id: " + id);
+ }
+ offset = Optional.of(fetchData.getNextOffset());
+ finished = fetchData.isFinish();
+ } while (!finished);
+ }
+
+ private void readStringPartition(MongoTableSource source, Configuration conf,
+ Partition partition, Set ids) throws IOException {
+ Optional offset = Optional.empty();
+ boolean finished;
+ TableDeserializer deserializer = source.getDeserializer(conf);
+ deserializer.init(conf, BOOKMARK_SCHEMA);
+ do {
+ FetchData fetchData = source.fetch(partition, offset,
+ new SizeFetchWindow(0, 1));
+ Iterator iterator = fetchData.getDataIterator();
+ while (iterator.hasNext()) {
+ Row row = deserializer.deserialize(iterator.next()).get(0);
+ BinaryString id = (BinaryString) row.getField(0, Types.BINARY_STRING);
+ Assert.assertTrue(ids.add(id.toString()), "Duplicate id: " + id);
+ }
+ offset = Optional.of(fetchData.getNextOffset());
+ finished = fetchData.isFinish();
+ } while (!finished);
+ }
+
+ private Row row(int id, String name) {
+ return ObjectRow.create(id, BinaryString.fromString(name), true);
+ }
+
+ private Row bookmarkRow(String id, int groupId) {
+ return ObjectRow.create(BinaryString.fromString(id), groupId);
+ }
+
+ private void insertDocuments(List documents) {
+ try (MongoClient client = MongoClients.create(container.getReplicaSetUrl())) {
+ client.getDatabase(DATABASE).getCollection(COLLECTION).insertMany(documents);
+ }
+ }
+
+ private long countDocuments() {
+ return countDocuments(new Document());
+ }
+
+ private long countDocuments(Document filter) {
+ try (MongoClient client = MongoClients.create(container.getReplicaSetUrl())) {
+ return client.getDatabase(DATABASE).getCollection(COLLECTION)
+ .countDocuments(filter);
+ }
+ }
+
+ private Configuration baseConfig() {
+ Configuration conf = new Configuration();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_URI, container.getReplicaSetUrl());
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_DATABASE, DATABASE);
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_COLLECTION, COLLECTION);
+ return conf;
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnectorTest.java b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnectorTest.java
new file mode 100644
index 000000000..0a4e9ed08
--- /dev/null
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/geaflow-dsl-connector-mongodb/src/test/java/org/apache/geaflow/dsl/connector/mongodb/MongoTableConnectorTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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.geaflow.dsl.connector.mongodb;
+
+import com.mongodb.MongoClientSettings;
+import java.util.List;
+import org.apache.geaflow.common.config.Configuration;
+import org.apache.geaflow.common.serialize.SerializerFactory;
+import org.apache.geaflow.common.type.Types;
+import org.apache.geaflow.dsl.common.exception.GeaFlowDSLException;
+import org.apache.geaflow.dsl.common.types.TableField;
+import org.apache.geaflow.dsl.common.types.TableSchema;
+import org.apache.geaflow.dsl.connector.api.Partition;
+import org.apache.geaflow.dsl.connector.api.TableConnector;
+import org.apache.geaflow.dsl.connector.api.util.ConnectorFactory;
+import org.bson.BsonDocument;
+import org.bson.BsonInt32;
+import org.bson.BsonString;
+import org.bson.conversions.Bson;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+public class MongoTableConnectorTest {
+
+ private static final TableSchema SCHEMA = new TableSchema(
+ new TableField("id", Types.INTEGER, false));
+
+ @Test
+ public void testLoadConnector() {
+ TableConnector connector = ConnectorFactory.loadConnector("mongodb");
+ Assert.assertEquals(connector.getType(), MongoTableConnector.TYPE);
+ }
+
+ @Test
+ public void testSinglePartition() {
+ MongoTableSource source = new MongoTableSource();
+ source.init(baseConfig(), SCHEMA);
+
+ List partitions = source.listPartitions();
+
+ Assert.assertEquals(partitions.size(), 1);
+ MongoPartition partition = (MongoPartition) partitions.get(0);
+ Assert.assertFalse(partition.hasRange());
+ }
+
+ @Test
+ public void testRangePartitions() {
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM, "3");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_FIELD, "id");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND, "0");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND, "10");
+ MongoTableSource source = new MongoTableSource();
+ source.init(conf, SCHEMA);
+
+ List partitions = source.listPartitions();
+
+ Assert.assertEquals(partitions.size(), 3);
+ assertBounds((MongoPartition) partitions.get(0), 0L, 4L);
+ assertBounds((MongoPartition) partitions.get(1), 4L, 7L);
+ assertBounds((MongoPartition) partitions.get(2), 7L, 10L);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*partition.upperbound.*")
+ public void testMissingPartitionBound() {
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM, "2");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_FIELD, "id");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND, "0");
+
+ new MongoTableSource().init(conf, SCHEMA);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*batch size.*")
+ public void testInvalidBatchSize() {
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_BATCH_SIZE, "0");
+
+ new MongoTableSink().init(conf, SCHEMA);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*partition number.*")
+ public void testInvalidPartitionNumber() {
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM, "0");
+
+ new MongoTableSource().init(conf, SCHEMA);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*upper bound.*")
+ public void testInvalidPartitionRange() {
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM, "2");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_FIELD, "id");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND, "10");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND, "10");
+
+ new MongoTableSource().init(conf, SCHEMA);
+ }
+
+ @Test(expectedExceptions = GeaFlowDSLException.class,
+ expectedExceptionsMessageRegExp = ".*mongodb.uri.*")
+ public void testMissingUri() {
+ Configuration conf = new Configuration();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_DATABASE, "geaflow");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_COLLECTION, "records");
+
+ new MongoTableSource().init(conf, SCHEMA);
+ }
+
+ @Test
+ public void testPartitionCountLimitedByRange() {
+ Configuration conf = baseConfig();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_NUM, "4");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_FIELD, "id");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_LOWERBOUND, "0");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_PARTITION_UPPERBOUND, "2");
+ MongoTableSource source = new MongoTableSource();
+ source.init(conf, SCHEMA);
+
+ List partitions = source.listPartitions();
+
+ Assert.assertEquals(partitions.size(), 2);
+ assertBounds((MongoPartition) partitions.get(0), 0L, 1L);
+ assertBounds((MongoPartition) partitions.get(1), 1L, 2L);
+ }
+
+ @Test
+ public void testSinglePartitionBookmarkFilter() {
+ BsonDocument bookmark = new BsonDocument(MongoOffset.ID_FIELD,
+ new BsonString("record-1"));
+
+ Bson filter = MongoTableSource.createBookmarkFilter(
+ new MongoPartition("records", 0), bookmark);
+
+ Assert.assertEquals(toBsonDocument(filter),
+ BsonDocument.parse("{'_id': {'$gt': 'record-1'}}"));
+ }
+
+ @Test
+ public void testRangePartitionBookmarkFilter() {
+ BsonDocument bookmark = new BsonDocument(MongoOffset.ID_FIELD, new BsonString("z"))
+ .append(MongoOffset.PARTITION_VALUE_FIELD, new BsonInt32(3));
+ MongoPartition partition = new MongoPartition("records", 0, "id", 0L, 10L);
+
+ Bson filter = MongoTableSource.createBookmarkFilter(partition, bookmark);
+
+ Assert.assertEquals(toBsonDocument(filter), BsonDocument.parse(
+ "{'$or': [{'id': {'$gt': 3}}, {'$and': [{'id': 3}, "
+ + "{'_id': {'$gt': 'z'}}]}]}"));
+ }
+
+ @Test
+ public void testBookmarkOffsetSerialization() {
+ BsonDocument bookmark = new BsonDocument(MongoOffset.ID_FIELD,
+ new BsonString("record-1"))
+ .append(MongoOffset.PARTITION_VALUE_FIELD, new BsonInt32(3));
+ MongoOffset offset = new MongoOffset(5L, bookmark);
+
+ byte[] bytes = SerializerFactory.getKryoSerializer().serialize(offset);
+ MongoOffset restored = (MongoOffset) SerializerFactory.getKryoSerializer()
+ .deserialize(bytes);
+
+ Assert.assertEquals(restored.getOffset(), 5L);
+ Assert.assertEquals(restored.getBookmark(), bookmark);
+ }
+
+ private static Configuration baseConfig() {
+ Configuration conf = new Configuration();
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_URI, "mongodb://localhost:27017");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_DATABASE, "geaflow");
+ conf.put(MongoConfigKeys.GEAFLOW_DSL_MONGODB_COLLECTION, "records");
+ return conf;
+ }
+
+ private static void assertBounds(MongoPartition partition, long lower, long upper) {
+ Assert.assertEquals(partition.getLowerBound(), Long.valueOf(lower));
+ Assert.assertEquals(partition.getUpperBound(), Long.valueOf(upper));
+ }
+
+ private static BsonDocument toBsonDocument(Bson filter) {
+ return filter.toBsonDocument(BsonDocument.class,
+ MongoClientSettings.getDefaultCodecRegistry());
+ }
+}
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-connector/pom.xml b/geaflow/geaflow-dsl/geaflow-dsl-connector/pom.xml
index 4fe03c6de..9782d73ad 100644
--- a/geaflow/geaflow-dsl/geaflow-dsl-connector/pom.xml
+++ b/geaflow/geaflow-dsl/geaflow-dsl-connector/pom.xml
@@ -50,6 +50,7 @@
geaflow-dsl-connector-neo4j
geaflow-dsl-connector-elasticsearch
geaflow-dsl-connector-doris
+ geaflow-dsl-connector-mongodb
diff --git a/geaflow/geaflow-dsl/geaflow-dsl-runtime/pom.xml b/geaflow/geaflow-dsl/geaflow-dsl-runtime/pom.xml
index 87c9f040a..f1e31d8bf 100644
--- a/geaflow/geaflow-dsl/geaflow-dsl-runtime/pom.xml
+++ b/geaflow/geaflow-dsl/geaflow-dsl-runtime/pom.xml
@@ -86,6 +86,11 @@
geaflow-dsl-connector-doris
+
+ org.apache.geaflow
+ geaflow-dsl-connector-mongodb
+
+
org.apache.geaflow
geaflow-dsl-connector-odps
diff --git a/geaflow/geaflow-dsl/pom.xml b/geaflow/geaflow-dsl/pom.xml
index a7a37063f..b644025ac 100644
--- a/geaflow/geaflow-dsl/pom.xml
+++ b/geaflow/geaflow-dsl/pom.xml
@@ -148,6 +148,12 @@
${project.version}
+
+ org.apache.geaflow
+ geaflow-dsl-connector-mongodb
+ ${project.version}
+
+
org.apache.geaflow
geaflow-dsl-connector-odps
diff --git a/geaflow/geaflow-examples/gql/mongodb_connector.sql b/geaflow/geaflow-examples/gql/mongodb_connector.sql
new file mode 100644
index 000000000..41b3f7cbe
--- /dev/null
+++ b/geaflow/geaflow-examples/gql/mongodb_connector.sql
@@ -0,0 +1,48 @@
+/*
+ * 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.
+ */
+
+CREATE TABLE mongo_source (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'source_records',
+ `geaflow.dsl.mongodb.partition.num` = '4',
+ `geaflow.dsl.mongodb.partition.field` = 'id',
+ `geaflow.dsl.mongodb.partition.lowerbound` = '0',
+ `geaflow.dsl.mongodb.partition.upperbound` = '100'
+);
+
+CREATE TABLE mongo_sink (
+ id BIGINT,
+ name VARCHAR,
+ active BOOLEAN
+) WITH (
+ type = 'mongodb',
+ `geaflow.dsl.mongodb.uri` = 'mongodb://localhost:27017',
+ `geaflow.dsl.mongodb.database` = 'geaflow',
+ `geaflow.dsl.mongodb.collection` = 'sink_records',
+ `geaflow.dsl.mongodb.batch.size` = '500'
+);
+
+INSERT INTO mongo_sink
+SELECT id, name, active FROM mongo_source;