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

Abacn 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 9811046f33a [DebeziumIO] Add opt-in full metadata (op and Debezium 
source fields) to readAsJson (#40702)
9811046f33a is described below

commit 9811046f33aca9de59ef389290f7229ca476a7e9
Author: Tobias Kaymak <[email protected]>
AuthorDate: Thu Oct 22 17:19:05 2026 +0200

    [DebeziumIO] Add opt-in full metadata (op and Debezium source fields) to 
readAsJson (#40702)
    
    * [DebeziumIO] Add opt-in full metadata (op and Debezium source fields) to 
readAsJson (#40701)
---
 .github/trigger_files/beam_PostCommit_Python.json  |   2 +-
 .../trigger_files/beam_PostCommit_XVR_Direct.json  |   2 +-
 sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go   |  17 +++
 .../pkg/beam/io/xlang/debeziumio/debezium_test.go  |  29 +++++
 sdks/java/io/debezium/src/README.md                |   2 +
 .../org/apache/beam/io/debezium/DebeziumIO.java    |  42 ++++++++
 .../io/debezium/DebeziumTransformRegistrar.java    |   9 ++
 .../beam/io/debezium/KafkaSourceConsumerFn.java    |   2 +-
 .../apache/beam/io/debezium/SourceRecordJson.java  | 119 ++++++++++++++++++++-
 .../io/debezium/DebeziumIOMySqlConnectorIT.java    |  74 +++++++++++++
 .../apache/beam/io/debezium/DebeziumIOTest.java    |  46 ++++++++
 .../beam/io/debezium/SourceRecordJsonTest.java     |  89 +++++++++++++++
 sdks/python/apache_beam/io/debezium.py             |  11 +-
 13 files changed, 435 insertions(+), 9 deletions(-)

diff --git a/.github/trigger_files/beam_PostCommit_Python.json 
b/.github/trigger_files/beam_PostCommit_Python.json
index b6d790320d0..15dcafe6956 100644
--- a/.github/trigger_files/beam_PostCommit_Python.json
+++ b/.github/trigger_files/beam_PostCommit_Python.json
@@ -1,5 +1,5 @@
 {
   "comment": "Modify this file in a trivial way to cause this test suite to 
run.",
   "pr": "40241",
-  "modification": 65
+  "modification": 66
 }
diff --git a/.github/trigger_files/beam_PostCommit_XVR_Direct.json 
b/.github/trigger_files/beam_PostCommit_XVR_Direct.json
index c04f8e34f87..49cff1ed291 100644
--- a/.github/trigger_files/beam_PostCommit_XVR_Direct.json
+++ b/.github/trigger_files/beam_PostCommit_XVR_Direct.json
@@ -1,4 +1,4 @@
 {
-  "modification": 2,
+  "modification": 3,
   "https://github.com/apache/beam/pull/39990": "removing dead code from 
FnApiDoFnRunner"
 }
diff --git a/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go 
b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go
index f31a71fbc9d..88101aed0bc 100644
--- a/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go
+++ b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium.go
@@ -79,6 +79,7 @@ type readFromDebeziumSchema struct {
        ConnectionProperties []string
        StartOffset          []string
        OffsetStoragePath    *string
+       FullMetadata         *bool
 }
 
 type debeziumConfig struct {
@@ -189,6 +190,22 @@ func OffsetStoragePath(path string) readOption {
        }
 }
 
+// FullMetadata adds op and all Debezium source fields not already present
+// (e.g. MySQL file, pos, row, ts_ms; PostgreSQL lsn, txId, ts_ms) to the
+// metadata of each JSON record. By default, only connector, version, name,
+// database, schema and table are included.
+//
+// Example:
+//
+//     debeziumio.Read(s, username, password, host, port, debeziumio.MySQL,
+//                     reflectx.String, debeziumio.FullMetadata())
+func FullMetadata() readOption {
+       return func(cfg *debeziumConfig) {
+               b := true
+               cfg.readSchema.FullMetadata = &b
+       }
+}
+
 // ExpansionAddr sets the expansion service address to use for DebeziumIO 
cross-langauage transform.
 func ExpansionAddr(expansionAddr string) readOption {
        return func(cfg *debeziumConfig) {
diff --git a/sdks/go/pkg/beam/io/xlang/debeziumio/debezium_test.go 
b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium_test.go
new file mode 100644
index 00000000000..165df74f77d
--- /dev/null
+++ b/sdks/go/pkg/beam/io/xlang/debeziumio/debezium_test.go
@@ -0,0 +1,29 @@
+// 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 debeziumio
+
+import "testing"
+
+func TestFullMetadata(t *testing.T) {
+       dc := debeziumConfig{readSchema: &readFromDebeziumSchema{}}
+       if dc.readSchema.FullMetadata != nil {
+               t.Fatalf("FullMetadata = %v by default, want nil", 
*dc.readSchema.FullMetadata)
+       }
+       FullMetadata()(&dc)
+       if got := dc.readSchema.FullMetadata; got == nil || !*got {
+               t.Errorf("FullMetadata() set FullMetadata = %v, want true", got)
+       }
+}
diff --git a/sdks/java/io/debezium/src/README.md 
b/sdks/java/io/debezium/src/README.md
index 677f42b59b0..cd5e4ceabac 100644
--- a/sdks/java/io/debezium/src/README.md
+++ b/sdks/java/io/debezium/src/README.md
@@ -93,6 +93,8 @@ If you want to use this built-in JSON Mapper, you can do it 
by setting an instan
 ```
 > **Note:** `SourceRecordJsonMapper`comes out of the box, but you may use any 
 > Format Function you prefer.
 
+By default, the JSON `metadata` object holds `connector`, `version`, `name`, 
`database`, `schema` and `table`. Call `withFullMetadata(true)` on 
`readAsJson()` to also add the change event's `op` (`c`, `u`, `d`, `r` or `t`) 
and every other field of the Debezium `source` block to `metadata` (for example 
`ts_ms`, MySQL `file`, `pos` and `row`, or PostgreSQL `lsn` and `txId`). Fields 
that `metadata` already carries aren't repeated. For MySQL, `file` is the 
actual binlog file name, which the  [...]
+
 ## Quick Example
 
 The following example is how an actual setup would look like using a **MySQL 
Debezium Connector** and **SourceRecordJsonMapper** as the Serializable 
Function.
diff --git 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java
 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java
index f12f9a95ded..cc31c451e9c 100644
--- 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java
+++ 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumIO.java
@@ -147,6 +147,8 @@ public class DebeziumIO {
 
     abstract @Nullable OffsetRetainer getOffsetRetainer();
 
+    abstract @Nullable Boolean getFullMetadata();
+
     abstract @Nullable Coder<T> getCoder();
 
     abstract Builder<T> toBuilder();
@@ -169,6 +171,8 @@ public class DebeziumIO {
 
       abstract Builder<T> setOffsetRetainer(OffsetRetainer retainer);
 
+      abstract Builder<T> setFullMetadata(Boolean fullMetadata);
+
       abstract Read<T> build();
     }
 
@@ -305,6 +309,44 @@ public class DebeziumIO {
       return toBuilder().setOffsetRetainer(retainer).build();
     }
 
+    /**
+     * Adds {@code op} and the remaining Debezium {@code source} fields (e.g. 
MySQL {@code file},
+     * {@code pos}, {@code row}, {@code ts_ms}; PostgreSQL {@code lsn}, {@code 
txId}, {@code ts_ms})
+     * to {@code metadata} when reading as JSON.
+     *
+     * <p>Only applies to {@link DebeziumIO#readAsJson()} / {@link
+     * SourceRecordJson.SourceRecordJsonMapper}; combining it with a custom 
{@link
+     * #withFormatFunction(SourceRecordMapper)} fails with an {@link 
IllegalArgumentException} when
+     * the transform is expanded. Default false.
+     *
+     * @param fullMetadata Whether to emit the full Debezium metadata.
+     * @return PTransform {@link #read}
+     */
+    public Read<T> withFullMetadata(boolean fullMetadata) {
+      return toBuilder().setFullMetadata(fullMetadata).build();
+    }
+
+    /**
+     * Returns the format function to use, taking {@link 
#withFullMetadata(boolean)} into account.
+     *
+     * @throws IllegalArgumentException if full metadata is requested but the 
format function is not
+     *     a {@link SourceRecordJson.SourceRecordJsonMapper}.
+     */
+    SourceRecordMapper<T> resolveFormatFunction() {
+      SourceRecordMapper<T> formatFunction = getFormatFunction();
+      if (!Boolean.TRUE.equals(getFullMetadata())) {
+        return formatFunction;
+      }
+      if (formatFunction instanceof SourceRecordJson.SourceRecordJsonMapper) {
+        @SuppressWarnings("unchecked")
+        SourceRecordMapper<T> fullMetadataMapper =
+            (SourceRecordMapper<T>) new 
SourceRecordJson.SourceRecordJsonMapper(true);
+        return fullMetadataMapper;
+      }
+      throw new IllegalArgumentException(
+          "withFullMetadata(true) only applies to readAsJson() / 
SourceRecordJsonMapper");
+    }
+
     protected Schema getRecordSchema() {
       KafkaSourceConsumerFn<T> fn =
           new 
KafkaSourceConsumerFn<>(getConnectorConfiguration().getConnectorClass().get(), 
this);
diff --git 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java
 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java
index 4b431ac38ba..b236935814a 100644
--- 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java
+++ 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/DebeziumTransformRegistrar.java
@@ -81,6 +81,7 @@ public class DebeziumTransformRegistrar implements 
ExternalTransformRegistrar {
       private @Nullable Long maxTimeToRun;
       private @Nullable List<String> startOffset;
       private @Nullable String offsetStoragePath;
+      private @Nullable Boolean fullMetadata;
 
       public void setConnectionProperties(@Nullable List<String> 
connectionProperties) {
         this.connectionProperties = connectionProperties;
@@ -101,6 +102,10 @@ public class DebeziumTransformRegistrar implements 
ExternalTransformRegistrar {
       public void setOffsetStoragePath(@Nullable String offsetStoragePath) {
         this.offsetStoragePath = offsetStoragePath;
       }
+
+      public void setFullMetadata(@Nullable Boolean fullMetadata) {
+        this.fullMetadata = fullMetadata;
+      }
     }
 
     @Override
@@ -161,6 +166,10 @@ public class DebeziumTransformRegistrar implements 
ExternalTransformRegistrar {
                 FileSystemOffsetRetainer.of(configuration.offsetStoragePath));
       }
 
+      if (Boolean.TRUE.equals(configuration.fullMetadata)) {
+        readTransform = readTransform.withFullMetadata(true);
+      }
+
       return readTransform;
     }
   }
diff --git 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java
 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java
index 89fc2a5c085..c9daf052de5 100644
--- 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java
+++ 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/KafkaSourceConsumerFn.java
@@ -113,7 +113,7 @@ public class KafkaSourceConsumerFn<T> extends 
DoFn<Map<String, String>, T> {
     // this(connectorClass, fn, maxRecords, null);
     this.connectorClass = (Class<? extends SourceConnector>) connectorClass;
     this.spec = spec;
-    this.fn = spec.getFormatFunction();
+    this.fn = spec.resolveFormatFunction();
     this.pollingTimeOut =
         MoreObjects.firstNonNull(spec.getPollingTimeout(), 
DEFAULT_POLLING_TIMEOUT);
   }
diff --git 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java
 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java
index 43b03605b55..694b232d464 100644
--- 
a/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java
+++ 
b/sdks/java/io/debezium/src/main/java/org/apache/beam/io/debezium/SourceRecordJson.java
@@ -19,10 +19,15 @@ package org.apache.beam.io.debezium;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import java.io.Serializable;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.Set;
 import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.Gson;
 import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.GsonBuilder;
+import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.JsonObject;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
 import org.apache.kafka.connect.data.Field;
 import org.apache.kafka.connect.data.Struct;
 import org.apache.kafka.connect.errors.DataException;
@@ -45,12 +50,32 @@ import org.checkerframework.checker.nullness.qual.Nullable;
  *   <li>after - {@link #loadAfter}
  *   <li>metadata - {@link #loadMetadata}
  *       <ul>
- *         <li>schema - Database Schema
  *         <li>connector - Connector used
  *         <li>version - Connector version
+ *         <li>name - Connector name
+ *         <li>database - Database name
+ *         <li>schema - Database Schema (for MySQL, the binlog file name)
+ *         <li>table - Table name
  *       </ul>
  * </ul>
  *
+ * <h3>Full metadata</h3>
+ *
+ * <p>When constructed with {@code fullMetadata = true} (see {@link
+ * SourceRecordJsonMapper#SourceRecordJsonMapper(boolean)} and {@link
+ * DebeziumIO.Read#withFullMetadata(boolean)}), extra keys are appended to 
{@code metadata} after
+ * {@code table}:
+ *
+ * <ul>
+ *   <li>op - The Debezium operation ({@code c}, {@code u}, {@code d}, {@code 
r}, {@code t}), if the
+ *       record envelope has an {@code op} field
+ *   <li>Every other field of the Debezium {@code source} block, in schema 
order, that is not
+ *       already represented above (for example {@code ts_ms}, {@code 
snapshot}, MySQL {@code file},
+ *       {@code pos}, {@code row}, or PostgreSQL {@code lsn}, {@code txId})
+ * </ul>
+ *
+ * <p>By default ({@code fullMetadata = false}) the output only contains the 
six keys above.
+ *
  * <h3>Usage Example</h3>
  *
  * <p>Map each SourceRecord to JSON
@@ -68,8 +93,16 @@ import org.checkerframework.checker.nullness.qual.Nullable;
             + " Making the class final or moving the throwing code"
             + " into a static factory method may fix it.")
 public class SourceRecordJson {
+  /**
+   * Source fields that are already represented in {@link Event.Metadata} (or 
would collide with an
+   * existing metadata key), so they are never added as full metadata extras.
+   */
+  private static final Set<String> RESERVED_METADATA_KEYS =
+      ImmutableSet.of("connector", "version", "name", "db", "database", 
"schema", "table", "op");
+
   private final @Nullable Struct value;
   private final @Nullable Event event;
+  private final Map<String, Object> metadataExtras;
 
   /**
    * Initializer.
@@ -77,6 +110,17 @@ public class SourceRecordJson {
    * @param sourceRecord retrieved SourceRecord using a supported 
SourceConnector
    */
   public SourceRecordJson(@Nullable SourceRecord sourceRecord) {
+    this(sourceRecord, false);
+  }
+
+  /**
+   * Initializer.
+   *
+   * @param sourceRecord retrieved SourceRecord using a supported 
SourceConnector
+   * @param fullMetadata whether to add {@code op} and the remaining Debezium 
{@code source} fields
+   *     to {@code metadata}
+   */
+  public SourceRecordJson(@Nullable SourceRecord sourceRecord, boolean 
fullMetadata) {
     if (sourceRecord == null) {
       throw new IllegalArgumentException();
     }
@@ -85,12 +129,15 @@ public class SourceRecordJson {
 
     if (this.value == null) {
       this.event = new Event(null, null, null);
+      this.metadataExtras = Collections.emptyMap();
     } else {
       Event.Metadata metadata = this.loadMetadata();
       Event.Before before = this.loadBefore();
       Event.After after = this.loadAfter();
 
       this.event = new Event(metadata, before, after);
+      this.metadataExtras =
+          fullMetadata && metadata != null ? this.loadMetadataExtras() : 
Collections.emptyMap();
     }
   }
 
@@ -129,6 +176,33 @@ public class SourceRecordJson {
         source.getString("table"));
   }
 
+  /**
+   * Extracts the full metadata extras: the envelope's {@code op} (if the 
value schema has one),
+   * then every field of the Debezium {@code source} block, in schema order, 
that isn't already
+   * represented in {@link Event.Metadata}.
+   *
+   * @return extra metadata keys, in insertion order
+   */
+  private Map<String, Object> loadMetadataExtras() {
+    Struct source = (Struct) this.value.get("source");
+    Map<String, Object> extras = new LinkedHashMap<>();
+    if (this.value.schema().field("op") != null) {
+      extras.put("op", toJsonFriendly(this.value.get("op")));
+    }
+    for (Field field : source.schema().fields()) {
+      String fieldName = field.name();
+      if (!RESERVED_METADATA_KEYS.contains(fieldName)) {
+        extras.put(fieldName, toJsonFriendly(source.get(field)));
+      }
+    }
+    return extras;
+  }
+
+  private static @Nullable Object toJsonFriendly(@Nullable Object value) {
+    // Source fields are flat primitives today; stringify a nested Struct to 
keep metadata flat.
+    return value instanceof Struct ? String.valueOf(value) : value;
+  }
+
   /**
    * Extracts the before field within SourceRecord.
    *
@@ -183,14 +257,31 @@ public class SourceRecordJson {
    * @return JSON String
    */
   public String toJson() {
-    return this.event.toJson();
+    return this.event.toJson(this.metadataExtras);
   }
 
   /** {@link SourceRecordJson} implementation. */
   public static class SourceRecordJsonMapper implements 
SourceRecordMapper<String> {
+    private final boolean fullMetadata;
+
+    /** Creates a mapper that emits the default metadata. */
+    public SourceRecordJsonMapper() {
+      this(false);
+    }
+
+    /**
+     * Creates a mapper.
+     *
+     * @param fullMetadata whether to add {@code op} and the remaining 
Debezium {@code source}
+     *     fields to {@code metadata}
+     */
+    public SourceRecordJsonMapper(boolean fullMetadata) {
+      this.fullMetadata = fullMetadata;
+    }
+
     @Override
     public String mapSourceRecord(SourceRecord sourceRecord) throws Exception {
-      return new SourceRecordJson(sourceRecord).toJson();
+      return new SourceRecordJson(sourceRecord, fullMetadata).toJson();
     }
   }
 
@@ -223,8 +314,28 @@ public class SourceRecordJson {
      * @return JSON String
      */
     public String toJson() {
+      return toJson(Collections.emptyMap());
+    }
+
+    /**
+     * Transforms the Event to a JSON string, appending the given extra keys 
to {@code metadata}
+     * after {@code table}.
+     *
+     * @param extras Additional metadata keys, in iteration order
+     * @return JSON String
+     */
+    String toJson(Map<String, Object> extras) {
       Gson gson = new GsonBuilder().serializeNulls().create();
-      return gson.toJson(this);
+      if (extras.isEmpty() || metadata == null) {
+        return gson.toJson(this);
+      }
+      // Gson can't flatten a map into an object, so append the extras to the 
metadata tree.
+      JsonObject tree = gson.toJsonTree(this).getAsJsonObject();
+      JsonObject metadataJson = tree.getAsJsonObject("metadata");
+      for (Map.Entry<String, Object> extra : extras.entrySet()) {
+        metadataJson.add(extra.getKey(), gson.toJsonTree(extra.getValue()));
+      }
+      return gson.toJson(tree);
     }
 
     /** Depicts the metadata within a SourceRecord. It has valuable fields. */
diff --git 
a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java
 
b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java
index 32edc4eb56b..29c8e33ac06 100644
--- 
a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java
+++ 
b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOMySqlConnectorIT.java
@@ -20,7 +20,11 @@ package org.apache.beam.io.debezium;
 import static 
org.apache.beam.io.debezium.DebeziumIOPostgresSqlConnectorIT.TABLE_SCHEMA;
 import static org.apache.beam.sdk.testing.SerializableMatchers.hasItem;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.not;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
 import com.zaxxer.hikari.HikariConfig;
@@ -31,6 +35,7 @@ import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
 import java.time.Duration;
+import java.util.List;
 import javax.sql.DataSource;
 import org.apache.beam.sdk.Pipeline;
 import org.apache.beam.sdk.coders.StringUtf8Coder;
@@ -44,6 +49,8 @@ import org.apache.beam.sdk.values.PCollection;
 import org.apache.beam.sdk.values.PCollectionRowTuple;
 import org.apache.beam.sdk.values.Row;
 import org.apache.beam.sdk.values.TypeDescriptors;
+import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.JsonObject;
+import org.apache.beam.vendor.grpc.v1p69p0.com.google.gson.JsonParser;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
 import org.junit.ClassRule;
 import org.junit.Test;
@@ -293,4 +300,71 @@ public class DebeziumIOMySqlConnectorIT {
 
     p.run().waitUntilFinish();
   }
+
+  /**
+   * Debezium - MySQL connector Test with {@link 
DebeziumIO.Read#withFullMetadata(boolean)}.
+   *
+   * <p>Tests that the JSON metadata carries {@code op} and the MySQL source 
fields, flattened.
+   */
+  @Test
+  public void testDebeziumIOMySqlFullMetadata() {
+
+    String kafkaBootstrapServers = KAFKA_CONTAINER.getBootstrapServers();
+    String schemaHistoryTopic = "mysql-schema-history-full-metadata-" + 
System.nanoTime();
+
+    String host = MY_SQL_CONTAINER.getHost();
+    String port = MY_SQL_CONTAINER.getMappedPort(3306).toString();
+
+    PipelineOptions options = PipelineOptionsFactory.create();
+    Pipeline p = Pipeline.create(options);
+    PCollection<String> results =
+        p.apply(
+            DebeziumIO.readAsJson()
+                .withConnectorConfiguration(
+                    DebeziumIO.ConnectorConfiguration.create()
+                        .withUsername("debezium")
+                        .withPassword("dbz")
+                        .withConnectorClass(MySqlConnector.class)
+                        .withHostName(host)
+                        .withPort(port)
+                        .withConnectionProperty("database.server.id", "184055")
+                        .withConnectionProperty("database.include.list", 
"inventory")
+                        .withConnectionProperty("include.schema.changes", 
"false")
+                        .withConnectionProperty(
+                            "schema.history.internal.kafka.bootstrap.servers",
+                            kafkaBootstrapServers)
+                        .withConnectionProperty(
+                            "schema.history.internal.kafka.topic", 
schemaHistoryTopic)
+                        .withConnectionProperty(
+                            "schema.history.internal",
+                            
"io.debezium.storage.kafka.history.KafkaSchemaHistory")
+                        .withConnectionProperty(
+                            
"schema.history.internal.store.only.captured.tables.ddl", "true"))
+                .withFullMetadata(true)
+                .withMaxNumberOfRecords(10));
+
+    PAssert.that(results)
+        .satisfies(
+            (Iterable<String> res) -> {
+              List<String> records = Lists.newArrayList(res);
+              assertThat(records, not(empty()));
+              for (String record : records) {
+                JsonObject metadata =
+                    
JsonParser.parseString(record).getAsJsonObject().getAsJsonObject("metadata");
+                // Snapshot rows are reads.
+                assertThat(metadata.get("op").getAsString(), equalTo("r"));
+                
assertTrue(metadata.get("pos").getAsJsonPrimitive().isNumber());
+                
assertTrue(metadata.get("row").getAsJsonPrimitive().isNumber());
+                
assertTrue(metadata.get("ts_ms").getAsJsonPrimitive().isNumber());
+                // MySQL keeps exposing the binlog file under "schema" for 
compatibility.
+                assertThat(
+                    metadata.get("file").getAsString(),
+                    equalTo(metadata.get("schema").getAsString()));
+                assertFalse(metadata.has("source"));
+              }
+              return null;
+            });
+
+    p.run().waitUntilFinish();
+  }
 }
diff --git 
a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java
 
b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java
index 074f0ba5b52..294b709dced 100644
--- 
a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java
+++ 
b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/DebeziumIOTest.java
@@ -20,6 +20,7 @@ package org.apache.beam.io.debezium;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
@@ -33,6 +34,7 @@ import java.util.Map;
 import org.apache.beam.io.debezium.DebeziumIO.ConnectorConfiguration;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
 import org.apache.kafka.common.config.ConfigValue;
+import org.apache.kafka.connect.source.SourceRecord;
 import org.checkerframework.checker.nullness.qual.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -235,6 +237,50 @@ public class DebeziumIOTest implements Serializable {
     assertEquals(explicitOffset, restriction.offset);
   }
 
+  @Test
+  public void testReadAsJsonWithFullMetadata() throws Exception {
+    SourceRecord record =
+        
SourceRecordJsonTest.buildEnvelopeRecord(SourceRecordJsonTest.postgresSource(), 
"u");
+    SourceRecordMapper<String> fn =
+        DebeziumIO.readAsJson().withFullMetadata(true).resolveFormatFunction();
+    assertEquals(new SourceRecordJson(record, true).toJson(), 
fn.mapSourceRecord(record));
+  }
+
+  @Test
+  public void testReadAsJsonWithoutFullMetadata() {
+    DebeziumIO.Read<String> read = DebeziumIO.readAsJson();
+    assertSame(read.getFormatFunction(), read.resolveFormatFunction());
+  }
+
+  @Test
+  public void testFullMetadataWithCustomFormatFunctionThrows() {
+    DebeziumIO.Read<String> read =
+        DebeziumIO.<String>read()
+            .withFormatFunction(sourceRecord -> sourceRecord.toString())
+            .withFullMetadata(true);
+    assertThrows(IllegalArgumentException.class, read::resolveFormatFunction);
+  }
+
+  @Test
+  public void testBuildExternalWithFullMetadata() throws Exception {
+    DebeziumTransformRegistrar.ReadBuilder.Configuration config =
+        new DebeziumTransformRegistrar.ReadBuilder.Configuration();
+    config.setUsername("user");
+    config.setPassword("pass");
+    config.setHost("localhost");
+    config.setPort("3306");
+    config.setConnectorClass("MySQL");
+    config.setFullMetadata(true);
+    DebeziumIO.Read<String> read =
+        (DebeziumIO.Read<String>)
+            new DebeziumTransformRegistrar.ReadBuilder().buildExternal(config);
+    SourceRecord record =
+        
SourceRecordJsonTest.buildEnvelopeRecord(SourceRecordJsonTest.postgresSource(), 
"u");
+    assertEquals(
+        new SourceRecordJson(record, true).toJson(),
+        read.resolveFormatFunction().mapSourceRecord(record));
+  }
+
   @Test
   public void testBuildExternalThrowsOnMalformedStartOffsetEntry() {
     DebeziumTransformRegistrar.ReadBuilder.Configuration config =
diff --git 
a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java
 
b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java
index add8844f48f..bcb33e97e4d 100644
--- 
a/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java
+++ 
b/sdks/java/io/debezium/src/test/java/org/apache/beam/io/debezium/SourceRecordJsonTest.java
@@ -18,13 +18,16 @@
 package org.apache.beam.io.debezium;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
 
 import java.io.Serializable;
 import org.apache.kafka.connect.data.Schema;
 import org.apache.kafka.connect.data.SchemaBuilder;
 import org.apache.kafka.connect.data.Struct;
 import org.apache.kafka.connect.source.SourceRecord;
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -58,6 +61,92 @@ public class SourceRecordJsonTest implements Serializable {
     assertThrows(IllegalArgumentException.class, () -> new 
SourceRecordJson(null));
   }
 
+  @Test
+  public void testFullMetadata() {
+    // MySQL has no "schema" source field, so the binlog "file" is also 
exposed as "schema".
+    Schema mySqlSchema =
+        sourceSchemaBuilder()
+            .field("gtid", Schema.OPTIONAL_STRING_SCHEMA)
+            .field("file", Schema.STRING_SCHEMA)
+            .field("pos", Schema.INT64_SCHEMA)
+            .field("row", Schema.INT32_SCHEMA)
+            .build();
+    Struct mySql =
+        sourceStruct(mySqlSchema, "mysql", "inventory")
+            .put("file", "mysql-bin.000003")
+            .put("pos", 796L)
+            .put("row", 0);
+    assertEquals(
+        
"{\"metadata\":{\"connector\":\"mysql\",\"version\":\"3.5.2.Final\",\"name\":\"n\","
+            + 
"\"database\":\"inventory\",\"schema\":\"mysql-bin.000003\",\"table\":\"customers\","
+            + 
"\"op\":\"c\",\"ts_ms\":1791689147000,\"gtid\":null,\"file\":\"mysql-bin.000003\","
+            + 
"\"pos\":796,\"row\":0},\"before\":null,\"after\":{\"fields\":{\"id\":1001}}}",
+        new SourceRecordJson(buildEnvelopeRecord(mySql, "c"), true).toJson());
+
+    // PostgreSQL "schema" is a source field: it stays the existing key and 
isn't repeated.
+    assertEquals(
+        
"{\"metadata\":{\"connector\":\"postgresql\",\"version\":\"3.5.2.Final\",\"name\":\"n\","
+            + 
"\"database\":\"postgres\",\"schema\":\"inventory\",\"table\":\"customers\","
+            + "\"op\":\"u\",\"ts_ms\":1791689147000,\"lsn\":37094192},"
+            + "\"before\":null,\"after\":{\"fields\":{\"id\":1001}}}",
+        new SourceRecordJson(buildEnvelopeRecord(postgresSource(), "u"), 
true).toJson());
+  }
+
+  @Test
+  public void testFullMetadataWithoutOpField() {
+    String json = new SourceRecordJson(buildEnvelopeRecord(postgresSource(), 
null), true).toJson();
+    assertFalse(json.contains("\"op\""));
+    assertTrue(json.contains("\"lsn\":37094192"));
+  }
+
+  private static SchemaBuilder sourceSchemaBuilder() {
+    return SchemaBuilder.struct()
+        .field("version", Schema.STRING_SCHEMA)
+        .field("connector", Schema.STRING_SCHEMA)
+        .field("name", Schema.STRING_SCHEMA)
+        .field("ts_ms", Schema.INT64_SCHEMA)
+        .field("db", Schema.STRING_SCHEMA)
+        .field("table", Schema.STRING_SCHEMA);
+  }
+
+  private static Struct sourceStruct(Schema schema, String connector, String 
db) {
+    return new Struct(schema)
+        .put("version", "3.5.2.Final")
+        .put("connector", connector)
+        .put("name", "n")
+        .put("ts_ms", 1791689147000L)
+        .put("db", db)
+        .put("table", "customers");
+  }
+
+  static Struct postgresSource() {
+    Schema schema =
+        sourceSchemaBuilder()
+            .field("schema", Schema.STRING_SCHEMA)
+            .field("lsn", Schema.INT64_SCHEMA)
+            .build();
+    return sourceStruct(schema, "postgresql", "postgres")
+        .put("schema", "inventory")
+        .put("lsn", 37094192L);
+  }
+
+  /** A Debezium-like envelope with {@code before} null, {@code after.id} and 
an optional op. */
+  static SourceRecord buildEnvelopeRecord(Struct source, @Nullable String op) {
+    Schema row = SchemaBuilder.struct().optional().field("id", 
Schema.INT32_SCHEMA).build();
+    SchemaBuilder envelope =
+        SchemaBuilder.struct()
+            .field("before", row)
+            .field("after", row)
+            .field("source", source.schema());
+    Schema schema = (op == null ? envelope : envelope.field("op", 
Schema.STRING_SCHEMA)).build();
+    Struct value =
+        new Struct(schema).put("after", new Struct(row).put("id", 
1001)).put("source", source);
+    if (op != null) {
+      value.put("op", op);
+    }
+    return new SourceRecord(null, null, null, schema, value);
+  }
+
   private static Schema buildSourceSchema() {
     return SchemaBuilder.struct()
         .field("connector", Schema.STRING_SCHEMA)
diff --git a/sdks/python/apache_beam/io/debezium.py 
b/sdks/python/apache_beam/io/debezium.py
index 5b5efe86fba..8e7b88da687 100644
--- a/sdks/python/apache_beam/io/debezium.py
+++ b/sdks/python/apache_beam/io/debezium.py
@@ -110,7 +110,7 @@ ReadFromDebeziumSchema = NamedTuple(
      ('host', str), ('port', str), ('max_number_of_records', Optional[int]),
      ('connection_properties', list[str]),
      ('start_offset', Optional[list[str]]),
-     ('offset_storage_path', Optional[str])])
+     ('offset_storage_path', Optional[str]), ('full_metadata', 
Optional[bool])])
 
 
 class ReadFromDebezium(PTransform):
@@ -134,6 +134,7 @@ class ReadFromDebezium(PTransform):
       connection_properties=None,
       start_offset=None,
       offset_storage_path=None,
+      full_metadata=None,
       expansion_service=None):
     """
         Initializes a read operation from Debezium.
@@ -179,6 +180,11 @@ class ReadFromDebezium(PTransform):
 
                                     When set, takes precedence over
                                     ``start_offset``.
+        :param full_metadata: when True, adds ``op`` and all Debezium source
+                              fields not already present (e.g. MySQL
+                              ``file``, ``pos``, ``row``, ``ts_ms``;
+                              PostgreSQL ``lsn``, ``txId``, ``ts_ms``) to
+                              ``metadata``. Defaults to False.
         :param expansion_service: The address (host:port)
                                   of the ExpansionService.
     """
@@ -192,7 +198,8 @@ class ReadFromDebezium(PTransform):
         max_number_of_records=max_number_of_records,
         connection_properties=connection_properties,
         start_offset=start_offset,
-        offset_storage_path=offset_storage_path)
+        offset_storage_path=offset_storage_path,
+        full_metadata=full_metadata)
     self.expansion_service = expansion_service or 
default_io_expansion_service()
 
   def expand(self, pbegin):

Reply via email to