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

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 32bef0e8a2 [Feature][Connector-V2][SqlServer CDC] support sqlserver 
schema evolution (#10890)
32bef0e8a2 is described below

commit 32bef0e8a262b2ff46808f117628b3591dbd5590
Author: cloverdew <[email protected]>
AuthorDate: Wed May 27 21:47:35 2026 +0800

    [Feature][Connector-V2][SqlServer CDC] support sqlserver schema evolution 
(#10890)
---
 .../cdc/base/utils/SourceRecordUtils.java          |   3 +-
 .../connector/sqlserver/SqlServerConnection.java   | 148 +++++-
 .../SqlServerStreamingChangeEventSource.java       | 182 +++++++
 .../config/SqlServerSourceConfigFactory.java       |   5 +-
 .../cdc/sqlserver/source/SqlServerDialect.java     |   2 +-
 .../source/SqlServerIncrementalSource.java         |  62 ++-
 .../source/SqlServerIncrementalSourceFactory.java  |   3 +-
 .../source/SqlServerSchemaChangeResolver.java      | 576 +++++++++++++++++++++
 .../sqlserver/SqlServerConnectionTest.java         |  17 +
 .../config/SqlServerSourceConfigFactoryTest.java   |  47 ++
 .../source/SqlServerSchemaChangeResolverTest.java  | 299 +++++++++++
 .../dialect/sqlserver/SqlServerDialect.java        |  98 +++-
 .../dialect/sqlserver/SqlServerDialectTest.java    |  60 +++
 .../connector/cdc/sqlserver/SqlServerCDCIT.java    | 183 ++++++-
 .../src/test/resources/ddl/schema_change_test.sql  |  47 ++
 .../ddl/sqlserver_schema_change_add_columns.sql    |  55 ++
 .../ddl/sqlserver_schema_change_drop_columns.sql   |  47 ++
 .../ddl/sqlserver_schema_change_modify_columns.sql |  36 ++
 .../ddl/sqlserver_schema_change_rename_columns.sql |  48 ++
 ...lservercdc_to_sqlserver_with_schema_change.conf |  52 ++
 20 files changed, 1909 insertions(+), 61 deletions(-)

diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/utils/SourceRecordUtils.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/utils/SourceRecordUtils.java
index 370fd93b36..7237d99dbc 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/utils/SourceRecordUtils.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-base/src/main/java/org/apache/seatunnel/connectors/cdc/base/utils/SourceRecordUtils.java
@@ -51,7 +51,8 @@ public class SourceRecordUtils {
     public static final List<String> SUPPORT_SCHEMA_CHANGE_EVENT_KEY_NAME =
             Arrays.asList(
                     "io.debezium.connector.mysql.SchemaChangeKey",
-                    "io.debezium.connector.oracle.SchemaChangeKey");
+                    "io.debezium.connector.oracle.SchemaChangeKey",
+                    "io.debezium.connector.sqlserver.SchemaChangeKey");
 
     public static final String HEARTBEAT_VALUE_SCHEMA_KEY_NAME =
             "io.debezium.connector.common.Heartbeat";
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerConnection.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerConnection.java
index 3a2bb3c729..0c790b954a 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerConnection.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerConnection.java
@@ -129,6 +129,15 @@ public class SqlServerConnection extends JdbcConnection {
 
     private static final String GET_NEW_CHANGE_TABLES =
             "SELECT * FROM [#db].cdc.change_tables WHERE start_lsn BETWEEN ? 
AND ?";
+    private static final String GET_DDL_HISTORY =
+            "SELECT OBJECT_SCHEMA_NAME(source_object_id, DB_ID(?)),"
+                    + " OBJECT_NAME(source_object_id, DB_ID(?)),"
+                    + " ddl_command,"
+                    + " ddl_lsn,"
+                    + " ddl_time"
+                    + " FROM [#db].cdc.ddl_history"
+                    + " WHERE ddl_lsn > ? AND ddl_lsn <= ?"
+                    + " ORDER BY ddl_lsn ASC";
     private static final String OPENING_QUOTING_CHARACTER = "[";
     private static final String CLOSING_QUOTING_CHARACTER = "]";
 
@@ -377,36 +386,29 @@ public class SqlServerConnection extends JdbcConnection {
     protected Optional<ColumnEditor> readTableColumn(
             ResultSet columnMetadata, TableId tableId, Tables.ColumnNameFilter 
columnFilter)
             throws SQLException {
-        return doReadTableColumn(columnMetadata, tableId, columnFilter);
+        return doReadTableColumn(columnMetadata, tableId, columnFilter, null);
     }
 
+    /**
+     * Reads a single column from the JDBC metadata ResultSet.
+     *
+     * @param columnTypeMapping pre-fetched UDT name map (column name → type 
name) for the table, or
+     *     {@code null} to fall back to per-column query
+     */
     private Optional<ColumnEditor> doReadTableColumn(
-            ResultSet columnMetadata, TableId tableId, Tables.ColumnNameFilter 
columnFilter)
+            ResultSet columnMetadata,
+            TableId tableId,
+            Tables.ColumnNameFilter columnFilter,
+            Map<String, String> columnTypeMapping)
             throws SQLException {
         // Oracle drivers require this for LONG/LONGRAW to be fetched first.
         final String defaultValue = columnMetadata.getString(13);
-        String tableSql =
-                StringUtils.isNotEmpty(tableId.table())
-                        ? "AND tbl.name = '" + tableId.table() + "'"
-                        : "";
-
-        Map<String, String> columnTypeMapping = new HashMap<>();
 
-        // Support user-defined types (UDTs)
-        try (PreparedStatement ps =
-                        connection()
-                                .prepareStatement(
-                                        String.format(
-                                                SELECT_COLUMNS_SQL_TEMPLATE,
-                                                tableId.schema(),
-                                                tableSql));
-                ResultSet resultSet = ps.executeQuery()) {
-            while (resultSet.next()) {
-                String columnName = resultSet.getString("column_name");
-                String dataType = resultSet.getString("type");
-                columnTypeMapping.put(columnName, dataType);
-            }
+        if (columnTypeMapping == null) {
+            // Fallback: fetch UDT mapping for this single call
+            columnTypeMapping = fetchColumnTypeMapping(tableId);
         }
+
         final String columnName = columnMetadata.getString(4);
         if (columnFilter == null
                 || columnFilter.matches(
@@ -446,6 +448,32 @@ public class SqlServerConnection extends JdbcConnection {
         return Optional.empty();
     }
 
+    /**
+     * Fetches UDT type names for all columns in {@code tableId} in a single 
query.
+     *
+     * @return map of column name -> resolved type name (empty map if the 
query returns no rows)
+     */
+    private Map<String, String> fetchColumnTypeMapping(TableId tableId) throws 
SQLException {
+        String tableSql =
+                StringUtils.isNotEmpty(tableId.table())
+                        ? "AND tbl.name = '" + tableId.table() + "'"
+                        : "";
+        Map<String, String> mapping = new HashMap<>();
+        try (PreparedStatement ps =
+                        connection()
+                                .prepareStatement(
+                                        String.format(
+                                                SELECT_COLUMNS_SQL_TEMPLATE,
+                                                tableId.schema(),
+                                                tableSql));
+                ResultSet resultSet = ps.executeQuery()) {
+            while (resultSet.next()) {
+                mapping.put(resultSet.getString("column_name"), 
resultSet.getString("type"));
+            }
+        }
+        return mapping;
+    }
+
     /**
      * Provides all changes recorder by the SQL Server CDC capture process for 
a set of tables.
      *
@@ -695,12 +723,42 @@ public class SqlServerConnection extends JdbcConnection {
                 });
     }
 
+    public List<SqlServerDdlEntry> getDdlHistory(String databaseName, Lsn 
fromLsn, Lsn toLsn)
+            throws SQLException {
+        final String query = replaceDatabaseNamePlaceholder(GET_DDL_HISTORY, 
databaseName);
+
+        return prepareQueryAndMap(
+                query,
+                ps -> {
+                    ps.setString(1, databaseName);
+                    ps.setString(2, databaseName);
+                    ps.setBytes(3, fromLsn.getBinary());
+                    ps.setBytes(4, toLsn.getBinary());
+                },
+                rs -> {
+                    final List<SqlServerDdlEntry> ddlEntries = new 
ArrayList<>();
+                    while (rs.next()) {
+                        ddlEntries.add(
+                                new SqlServerDdlEntry(
+                                        new TableId(databaseName, 
rs.getString(1), rs.getString(2)),
+                                        rs.getString(3),
+                                        Lsn.valueOf(rs.getBytes(4)),
+                                        rs.getTimestamp(5)));
+                    }
+                    return ddlEntries;
+                });
+    }
+
     public Table getTableSchemaFromTable(String databaseName, 
SqlServerChangeTable changeTable)
             throws SQLException {
         final DatabaseMetaData metadata = connection().getMetaData();
         JdbcIdentifierUtils.IdentifierCaseStrategy identifierCaseStrategy =
                 JdbcIdentifierUtils.identifierCaseStrategy(metadata);
 
+        // Fetch UDT type mapping once for the whole table to avoid N queries 
inside the column
+        final Map<String, String> columnTypeMapping =
+                fetchColumnTypeMapping(changeTable.getSourceTableId());
+
         List<Column> columns = new ArrayList<>();
         int filteredRows = 0;
         try (ResultSet rs =
@@ -728,7 +786,7 @@ public class SqlServerConnection extends JdbcConnection {
                     filteredRows++;
                     continue;
                 }
-                readTableColumn(rs, changeTable.getSourceTableId(), null)
+                doReadTableColumn(rs, changeTable.getSourceTableId(), null, 
columnTypeMapping)
                         .ifPresent(
                                 ce -> {
                                     // Filter out columns not included in the 
change table.
@@ -763,6 +821,50 @@ public class SqlServerConnection extends JdbcConnection {
         return captureName + "_CT";
     }
 
+    public static class SqlServerDdlEntry {
+        private final TableId sourceTableId;
+        private final String ddl;
+        private final Lsn ddlLsn;
+        private final java.sql.Timestamp ddlTime;
+
+        public SqlServerDdlEntry(
+                TableId sourceTableId, String ddl, Lsn ddlLsn, 
java.sql.Timestamp ddlTime) {
+            this.sourceTableId = sourceTableId;
+            this.ddl = ddl;
+            this.ddlLsn = ddlLsn;
+            this.ddlTime = ddlTime;
+        }
+
+        public TableId getSourceTableId() {
+            return sourceTableId;
+        }
+
+        public String getDdl() {
+            return ddl;
+        }
+
+        public Lsn getDdlLsn() {
+            return ddlLsn;
+        }
+
+        public java.sql.Timestamp getDdlTime() {
+            return ddlTime;
+        }
+
+        @Override
+        public String toString() {
+            return "SqlServerDdlEntry{"
+                    + "sourceTableId="
+                    + sourceTableId
+                    + ", ddlLsn="
+                    + ddlLsn
+                    + ", ddl='"
+                    + ddl
+                    + "'"
+                    + '}';
+        }
+    }
+
     /**
      * Retrieve the name of the database in the original case as it's defined 
on the server.
      *
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerStreamingChangeEventSource.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerStreamingChangeEventSource.java
index 68438f4ad2..57d1a40276 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerStreamingChangeEventSource.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/io/debezium/connector/sqlserver/SqlServerStreamingChangeEventSource.java
@@ -37,6 +37,7 @@ import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -240,6 +241,8 @@ public class SqlServerStreamingChangeEventSource
                                         databaseName, 
lastProcessedPosition.getCommitLsn())
                                 : lastProcessedPosition.getCommitLsn();
                 streamingExecutionContext.setShouldIncreaseFromLsn(true);
+                final Queue<SqlServerConnection.SqlServerDdlEntry> ddlEntries =
+                        getDdlEntriesToQuery(databaseName, fromLsn, toLsn);
 
                 while (!schemaChangeCheckpoints.isEmpty()) {
                     migrateTable(partition, schemaChangeCheckpoints, 
offsetContext);
@@ -396,6 +399,14 @@ public class SqlServerStreamingChangeEventSource
                                                     offsetContext);
                                         }
                                     }
+                                    dispatchPendingDdlHistory(
+                                            partition,
+                                            offsetContext,
+                                            tablesSlot.get(),
+                                            ddlEntries,
+                                            tableWithSmallestLsn
+                                                    .getChangePosition()
+                                                    .getCommitLsn());
                                     final TableId tableId =
                                             tableWithSmallestLsn
                                                     .getChangeTable()
@@ -455,6 +466,12 @@ public class SqlServerStreamingChangeEventSource
                                                     clock));
                                     tableWithSmallestLsn.next();
                                 }
+                                dispatchPendingDdlHistory(
+                                        partition,
+                                        offsetContext,
+                                        tablesSlot.get(),
+                                        ddlEntries,
+                                        null);
                             });
                     streamingExecutionContext.setLastProcessedPosition(
                             TxLogPosition.valueOf(toLsn));
@@ -512,6 +529,171 @@ public class SqlServerStreamingChangeEventSource
         newTable.setSourceTable(tableSchema);
     }
 
+    private Queue<SqlServerConnection.SqlServerDdlEntry> getDdlEntriesToQuery(
+            String databaseName, Lsn fromLsn, Lsn toLsn) throws SQLException {
+        Queue<SqlServerConnection.SqlServerDdlEntry> ddlEntries =
+                new PriorityQueue<>(
+                        
Comparator.comparing(SqlServerConnection.SqlServerDdlEntry::getDdlLsn));
+        dataConnection.getDdlHistory(databaseName, fromLsn, toLsn).stream()
+                .filter(
+                        ddlEntry ->
+                                connectorConfig
+                                        .getTableFilters()
+                                        .dataCollectionFilter()
+                                        
.isIncluded(ddlEntry.getSourceTableId()))
+                .forEach(ddlEntries::add);
+        return ddlEntries;
+    }
+
+    private void dispatchPendingDdlHistory(
+            SqlServerPartition partition,
+            SqlServerOffsetContext offsetContext,
+            SqlServerChangeTable[] currentTables,
+            Queue<SqlServerConnection.SqlServerDdlEntry> ddlEntries,
+            Lsn currentCommitLsn)
+            throws InterruptedException, SQLException {
+        while (!ddlEntries.isEmpty()
+                && (currentCommitLsn == null
+                        || 
ddlEntries.peek().getDdlLsn().compareTo(currentCommitLsn) <= 0)) {
+            SqlServerConnection.SqlServerDdlEntry ddlEntry = ddlEntries.poll();
+            dispatchDdlHistorySchemaChange(partition, offsetContext, 
currentTables, ddlEntry);
+        }
+    }
+
+    private void dispatchDdlHistorySchemaChange(
+            SqlServerPartition partition,
+            SqlServerOffsetContext offsetContext,
+            SqlServerChangeTable[] currentTables,
+            SqlServerConnection.SqlServerDdlEntry ddlEntry)
+            throws InterruptedException, SQLException {
+        SqlServerChangeTable currentTable =
+                findOldestActiveChangeTable(currentTables, 
ddlEntry.getSourceTableId());
+        if (currentTable == null) {
+            LOGGER.warn("Ignoring SQL Server DDL history for unknown captured 
table {}", ddlEntry);
+            return;
+        }
+
+        // When a DDL event is detected via the DDL history, a newer capture 
instance may exist
+        // (or be pending) for the same source table.  Dispatching the schema 
change here would
+        // update the Debezium internal schema registry before the old capture 
instance finishes
+        // streaming its records.  Subsequent records from the old capture 
table (which still
+        // use the pre-DDL column layout) would then be validated against the 
wider schema and
+        // crash with "Data row is smaller than a column index".
+        //
+        // The schema transition is already handled at the correct LSN 
boundary by migrateTable()
+        // when the commit LSN reaches the new capture instance's startLsn.  
Therefore, defer
+        // whenever a newer capture instance is known to exist.
+        if (currentTable.getStopLsn().isAvailable()) {
+            // currentTable is the old capture (a newer one is already 
in-flight).
+            // migrateTable() will handle the transition at the right boundary.
+            LOGGER.info(
+                    "Skipping DDL history dispatch for {} - newer capture 
instance pending; "
+                            + "schema transition deferred to migrateTable()",
+                    ddlEntry.getSourceTableId());
+            return;
+        }
+
+        // No sibling capture in currentTables; check whether a newer capture 
instance exists
+        // outside the current streaming window (its startLsn > current toLsn).
+        SqlServerChangeTable newerTable =
+                findNewerCaptureInstance(
+                        partition.getDatabaseName(), 
ddlEntry.getSourceTableId(), currentTable);
+        if (newerTable != null) {
+            // A newer capture instance exists but has not entered the window 
yet.
+            // migrateTable() will handle the transition once its startLsn is 
reached.
+            LOGGER.info(
+                    "Skipping DDL history dispatch for {} - newer capture 
instance exists "
+                            + "outside current window; schema transition 
deferred to migrateTable()",
+                    ddlEntry.getSourceTableId());
+            return;
+        }
+
+        Table oldTableSchema = schema.tableFor(ddlEntry.getSourceTableId());
+        Table tableSchema =
+                metadataConnection.getTableSchemaFromTable(
+                        partition.getDatabaseName(), currentTable);
+        if (oldTableSchema != null && oldTableSchema.equals(tableSchema)) {
+            LOGGER.info("Migration skipped, no table schema changes detected 
for {}", ddlEntry);
+            return;
+        }
+        
offsetContext.setChangePosition(TxLogPosition.valueOf(ddlEntry.getDdlLsn()), 1);
+        offsetContext.event(
+                ddlEntry.getSourceTableId(),
+                ddlEntry.getDdlTime() == null ? Instant.now() : 
ddlEntry.getDdlTime().toInstant());
+        LOGGER.info(
+                "Dispatching SQL Server DDL history schema change for {} at 
{}: {}",
+                ddlEntry.getSourceTableId(),
+                ddlEntry.getDdlLsn(),
+                ddlEntry.getDdl());
+        dispatcher.dispatchSchemaChangeEvent(
+                partition,
+                ddlEntry.getSourceTableId(),
+                new SqlServerSchemaChangeEventEmitter(
+                        partition,
+                        offsetContext,
+                        currentTable,
+                        tableSchema,
+                        SchemaChangeEventType.ALTER));
+        currentTable.setSourceTable(tableSchema);
+    }
+
+    /**
+     * Returns the oldest (currently streaming) capture instance for {@code 
sourceTableId} in the
+     * active tables array, i.e. the one whose {@code stopLsn} is set (meaning 
a newer capture is
+     * already in-flight), or the sole capture instance if only one exists for 
the table.
+     *
+     * <p>This is used in {@link #dispatchDdlHistorySchemaChange} to make sure 
we operate on the
+     * capture instance that is actually feeding records at the current LSN, 
not a future one.
+     */
+    private SqlServerChangeTable findOldestActiveChangeTable(
+            SqlServerChangeTable[] currentTables, TableId sourceTableId) {
+        if (currentTables == null) {
+            return null;
+        }
+        SqlServerChangeTable oldest = null;
+        for (SqlServerChangeTable table : currentTables) {
+            if (!table.getSourceTableId().equals(sourceTableId)) {
+                continue;
+            }
+            if (oldest == null) {
+                oldest = table;
+            } else if (table.getStopLsn().isAvailable() && 
!oldest.getStopLsn().isAvailable()) {
+                // prefer the one with a stopLsn (the old capture) over the 
unbounded one
+                oldest = table;
+            } else if (table.getStartLsn().compareTo(oldest.getStartLsn()) < 
0) {
+                oldest = table;
+            }
+        }
+        return oldest;
+    }
+
+    /**
+     * Queries all active change tables (without an LSN upper-bound) to find a 
capture instance for
+     * {@code sourceTableId} whose start LSN is strictly greater than {@code 
knownTable}'s start
+     * LSN. This is used to detect a newer capture instance that was created 
as part of a
+     * schema-change operation but has not yet entered the active streaming 
window.
+     *
+     * @return the newest capture instance beyond {@code knownTable}, or 
{@code null} if none
+     */
+    private SqlServerChangeTable findNewerCaptureInstance(
+            String databaseName, TableId sourceTableId, SqlServerChangeTable 
knownTable)
+            throws SQLException {
+        List<SqlServerChangeTable> allTables = 
dataConnection.getChangeTables(databaseName);
+        SqlServerChangeTable newest = null;
+        for (SqlServerChangeTable table : allTables) {
+            if (!table.getSourceTableId().equals(sourceTableId)) {
+                continue;
+            }
+            if (table.getStartLsn().compareTo(knownTable.getStartLsn()) <= 0) {
+                continue;
+            }
+            if (newest == null || 
table.getStartLsn().compareTo(newest.getStartLsn()) > 0) {
+                newest = table;
+            }
+        }
+        return newest;
+    }
+
     private SqlServerChangeTable[] processErrorFromChangeTableQuery(
             String databaseName, SQLException exception, 
SqlServerChangeTable[] currentChangeTables)
             throws Exception {
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/config/SqlServerSourceConfigFactory.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/config/SqlServerSourceConfigFactory.java
index c0d96932d8..9de44488cc 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/config/SqlServerSourceConfigFactory.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/config/SqlServerSourceConfigFactory.java
@@ -33,6 +33,7 @@ public class SqlServerSourceConfigFactory extends 
JdbcSourceConfigFactory {
 
     private static final String DATABASE_SERVER_NAME = 
"sqlserver_transaction_log_source";
     private static final String DRIVER_CLASS_NAME = 
"com.microsoft.sqlserver.jdbc.SQLServerDriver";
+    public static final String SCHEMA_CHANGE_KEY = "include.schema.changes";
 
     @Override
     public SqlServerSourceConfig create(int subtask) {
@@ -58,8 +59,8 @@ public class SqlServerSourceConfigFactory extends 
JdbcSourceConfigFactory {
         props.setProperty("database.history.skip.unparseable.ddl", 
String.valueOf(true));
         props.setProperty("database.history.refer.ddl", String.valueOf(true));
 
-        // TODO Not yet supported
-        props.setProperty("include.schema.changes", String.valueOf(false));
+        // Emit Debezium schema change records only when schema evolution is 
enabled.
+        props.setProperty(SCHEMA_CHANGE_KEY, 
String.valueOf(schemaChangeEnabled));
 
         if (databaseList != null) {
             props.setProperty("database.include.list", String.join(",", 
databaseList));
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerDialect.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerDialect.java
index 55838d16b1..5c191ad507 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerDialect.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerDialect.java
@@ -51,7 +51,7 @@ import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
 
-/** The {@link JdbcDataSourceDialect} implementation for MySQL datasource. */
+/** The {@link JdbcDataSourceDialect} implementation for SQL Server 
datasource. */
 public class SqlServerDialect implements JdbcDataSourceDialect {
 
     private static final long serialVersionUID = 1L;
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSource.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSource.java
index 4b046a7a7e..c80340d99b 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSource.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSource.java
@@ -20,8 +20,11 @@ package 
org.apache.seatunnel.connectors.seatunnel.cdc.sqlserver.source;
 import org.apache.seatunnel.api.configuration.Option;
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.source.SupportParallelism;
+import org.apache.seatunnel.api.source.SupportSchemaEvolution;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.schema.SchemaChangeType;
 import org.apache.seatunnel.common.utils.JdbcUrlUtil;
+import org.apache.seatunnel.common.utils.SeaTunnelException;
 import org.apache.seatunnel.connectors.cdc.base.config.JdbcSourceConfig;
 import org.apache.seatunnel.connectors.cdc.base.config.SourceConfig;
 import org.apache.seatunnel.connectors.cdc.base.dialect.DataSourceDialect;
@@ -30,6 +33,7 @@ import 
org.apache.seatunnel.connectors.cdc.base.option.StartupMode;
 import org.apache.seatunnel.connectors.cdc.base.option.StopMode;
 import org.apache.seatunnel.connectors.cdc.base.source.IncrementalSource;
 import org.apache.seatunnel.connectors.cdc.base.source.offset.OffsetFactory;
+import 
org.apache.seatunnel.connectors.cdc.debezium.ConnectTableChangeSerializer;
 import 
org.apache.seatunnel.connectors.cdc.debezium.DebeziumDeserializationSchema;
 import org.apache.seatunnel.connectors.cdc.debezium.DeserializeFormat;
 import 
org.apache.seatunnel.connectors.cdc.debezium.row.DebeziumJsonDeserializeSchema;
@@ -39,12 +43,23 @@ import 
org.apache.seatunnel.connectors.seatunnel.cdc.sqlserver.source.offset.Lsn
 import 
org.apache.seatunnel.connectors.seatunnel.jdbc.catalog.sqlserver.SqlServerURLParser;
 import org.apache.seatunnel.connectors.seatunnel.jdbc.config.JdbcCommonOptions;
 
+import org.apache.kafka.connect.data.Struct;
+
+import io.debezium.jdbc.JdbcConnection;
+import io.debezium.relational.TableId;
+import io.debezium.relational.history.TableChanges;
+
 import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 
 public class SqlServerIncrementalSource<T> extends IncrementalSource<T, 
JdbcSourceConfig>
-        implements SupportParallelism {
+        implements SupportParallelism, SupportSchemaEvolution {
 
     static final String IDENTIFIER = "SqlServer-CDC";
 
@@ -84,11 +99,16 @@ public class SqlServerIncrementalSource<T> extends 
IncrementalSource<T, JdbcSour
     @Override
     public DebeziumDeserializationSchema<T> 
createDebeziumDeserializationSchema(
             ReadonlyConfig config) {
+        boolean schemaChangesEnabled =
+                
config.get(SqlServerIncrementalSourceOptions.SCHEMA_CHANGES_ENABLED);
+        Map<TableId, Struct> tableIdTableChangeMap =
+                schemaChangesEnabled ? tableChanges() : Collections.emptyMap();
         if (DeserializeFormat.COMPATIBLE_DEBEZIUM_JSON.equals(
                 config.get(JdbcSourceOptions.FORMAT))) {
             return (DebeziumDeserializationSchema<T>)
                     new DebeziumJsonDeserializeSchema(
-                            config.get(JdbcSourceOptions.DEBEZIUM_PROPERTIES));
+                            config.get(JdbcSourceOptions.DEBEZIUM_PROPERTIES),
+                            tableIdTableChangeMap);
         }
 
         String zoneId = config.get(JdbcSourceOptions.SERVER_TIME_ZONE);
@@ -96,6 +116,9 @@ public class SqlServerIncrementalSource<T> extends 
IncrementalSource<T, JdbcSour
                 SeaTunnelRowDebeziumDeserializeSchema.builder()
                         .setTables(catalogTables)
                         .setServerTimeZone(ZoneId.of(zoneId))
+                        .setTableIdTableChangeMap(tableIdTableChangeMap)
+                        .setSchemaChangeResolver(
+                                schemaChangesEnabled ? new 
SqlServerSchemaChangeResolver() : null)
                         .build();
     }
 
@@ -114,4 +137,39 @@ public class SqlServerIncrementalSource<T> extends 
IncrementalSource<T, JdbcSour
     public Optional<String> driverName() {
         return Optional.of("com.microsoft.sqlserver.jdbc.SQLServerDriver");
     }
+
+    @Override
+    public List<SchemaChangeType> supports() {
+        return Arrays.asList(
+                SchemaChangeType.ADD_COLUMN,
+                SchemaChangeType.DROP_COLUMN,
+                SchemaChangeType.UPDATE_COLUMN,
+                SchemaChangeType.RENAME_COLUMN);
+    }
+
+    private Map<TableId, Struct> tableChanges() {
+        // Reuse the already-initialized dialect and config rather than 
constructing a second
+        SqlServerDialect dialect = (SqlServerDialect) dataSourceDialect;
+        JdbcSourceConfig jdbcSourceConfig = configFactory.create(0);
+        List<TableId> discoverTables = 
dialect.discoverDataCollections(jdbcSourceConfig);
+        ConnectTableChangeSerializer connectTableChangeSerializer =
+                new ConnectTableChangeSerializer();
+        try (JdbcConnection jdbcConnection = 
dialect.openJdbcConnection(jdbcSourceConfig)) {
+            return discoverTables.stream()
+                    .collect(
+                            Collectors.toMap(
+                                    Function.identity(),
+                                    tableId -> {
+                                        TableChanges tableChanges = new 
TableChanges();
+                                        tableChanges.create(
+                                                
dialect.queryTableSchema(jdbcConnection, tableId)
+                                                        .getTable());
+                                        return connectTableChangeSerializer
+                                                .serialize(tableChanges)
+                                                .get(0);
+                                    }));
+        } catch (Exception e) {
+            throw new SeaTunnelException(e);
+        }
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java
index 2046353a20..9436bc885f 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerIncrementalSourceFactory.java
@@ -71,7 +71,8 @@ public class SqlServerIncrementalSourceFactory implements 
TableSourceFactory {
                         
SqlServerIncrementalSourceOptions.SAMPLE_SHARDING_THRESHOLD,
                         
SqlServerIncrementalSourceOptions.INVERSE_SAMPLING_RATE,
                         SqlServerIncrementalSourceOptions.SPLIT_ALLOW_SAMPLING,
-                        SqlServerIncrementalSourceOptions.TABLE_NAMES_CONFIG)
+                        SqlServerIncrementalSourceOptions.TABLE_NAMES_CONFIG,
+                        
SqlServerIncrementalSourceOptions.SCHEMA_CHANGES_ENABLED)
                 .optional(
                         SqlServerIncrementalSourceOptions.STARTUP_MODE,
                         SqlServerIncrementalSourceOptions.STOP_MODE)
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerSchemaChangeResolver.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerSchemaChangeResolver.java
new file mode 100644
index 0000000000..b296a04278
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/main/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerSchemaChangeResolver.java
@@ -0,0 +1,576 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.cdc.sqlserver.source;
+
+import org.apache.seatunnel.shade.org.apache.commons.lang3.StringUtils;
+
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.Column;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TablePath;
+import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableChangeColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableColumnsEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableDropColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableModifyColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
+import org.apache.seatunnel.connectors.cdc.base.schema.SchemaChangeResolver;
+import org.apache.seatunnel.connectors.cdc.base.utils.SourceRecordUtils;
+import 
org.apache.seatunnel.connectors.cdc.debezium.ConnectTableChangeSerializer;
+import 
org.apache.seatunnel.connectors.seatunnel.cdc.sqlserver.utils.SqlServerTypeUtils;
+import 
org.apache.seatunnel.connectors.seatunnel.jdbc.internal.dialect.DatabaseIdentifier;
+
+import org.apache.kafka.connect.data.Struct;
+import org.apache.kafka.connect.source.SourceRecord;
+
+import io.debezium.relational.Table;
+import io.debezium.relational.history.HistoryRecord;
+import io.debezium.relational.history.TableChanges;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@Slf4j
+public class SqlServerSchemaChangeResolver implements SchemaChangeResolver {
+
+    private static final String SOURCE_DIALECT = DatabaseIdentifier.SQLSERVER;
+    private static final String SQLSERVER_SCHEMA_CHANGE_KEY =
+            "io.debezium.connector.sqlserver.SchemaChangeKey";
+
+    // This value must stay in sync with the entry in
+    // SourceRecordUtils.SUPPORT_SCHEMA_CHANGE_EVENT_KEY_NAME.
+    // TODO: expose it as a named constant in SourceRecordUtils and reference 
it here.
+    private static final Pattern ALTER_TABLE_PATTERN =
+            Pattern.compile(
+                    
"(?i)ALTER\\s+TABLE\\s+(.+?)\\s+(ADD|DROP|ALTER|RENAME|WITH|SWITCH)\\b");
+    private static final Pattern SP_RENAME_COLUMN_PATTERN =
+            Pattern.compile(
+                    
"(?i)(?:EXEC(?:UTE)?\\s+)?(?:(?:\\[[^\\]]+\\]|[^.\\s]+)\\.)?(?:sys\\.)?sp_rename\\s+N?'([^']+)'\\s*,\\s*N?'([^']+)'\\s*,\\s*N?'COLUMN'");
+    private static final Pattern TABLE_IDENTIFIER_PART_PATTERN =
+            
Pattern.compile("\\[([^\\]]+)]|\"([^\"]+)\"|`([^`]+)`|([^\\.\\s]+)");
+
+    private final ConnectTableChangeSerializer tableChangeSerializer =
+            new ConnectTableChangeSerializer();
+
+    @Override
+    public boolean support(SourceRecord record) {
+        if (!isSqlServerSchemaChangeEvent(record)) {
+            return false;
+        }
+        Struct value = (Struct) record.value();
+        List<Struct> tableChanges = 
value.getArray(HistoryRecord.Fields.TABLE_CHANGES);
+        return tableChanges != null && !tableChanges.isEmpty();
+    }
+
+    @Override
+    public SchemaChangeEvent resolve(SourceRecord record, List<CatalogTable> 
catalogTables) {
+        TablePath tablePath = resolveTablePath(record);
+        CatalogTable currentCatalogTable = findCatalogTable(catalogTables, 
tablePath);
+        if (currentCatalogTable == null) {
+            log.warn("Ignoring SQL Server schema change for unknown table {}", 
tablePath);
+            return null;
+        }
+
+        Table currentTable = getCurrentTable(record, tablePath);
+        if (currentTable == null) {
+            log.warn(
+                    "Ignoring SQL Server schema change with missing table 
change payload {}",
+                    tablePath);
+            return null;
+        }
+
+        String ddl = SourceRecordUtils.getDdl(record);
+        List<AlterTableColumnEvent> events =
+                diffColumns(currentCatalogTable, currentTable, 
parseSpRenameColumns(ddl));
+        if (events.isEmpty()) {
+            log.info(
+                    "Ignoring SQL Server schema change without column diff for 
table {}",
+                    tablePath);
+            return null;
+        }
+
+        TableIdentifier tableIdentifier = currentCatalogTable.getTableId();
+        AlterTableColumnsEvent event = new 
AlterTableColumnsEvent(tableIdentifier, events);
+        event.setStatement(ddl);
+        event.setSourceDialectName(SOURCE_DIALECT);
+        return event;
+    }
+
+    private CatalogTable findCatalogTable(List<CatalogTable> catalogTables, 
TablePath tablePath) {
+        if (catalogTables == null) {
+            return null;
+        }
+        return catalogTables.stream()
+                .filter(table -> isSameTablePath(table.getTablePath(), 
tablePath))
+                .findFirst()
+                .orElse(null);
+    }
+
+    private TablePath resolveTablePath(SourceRecord record) {
+        TablePath pathFromSource = SourceRecordUtils.getTablePath(record);
+        TablePath pathFromDdl = 
parseTablePathFromDdl(SourceRecordUtils.getDdl(record));
+        if (pathFromDdl == null) {
+            return pathFromSource;
+        }
+        return TablePath.of(
+                StringUtils.defaultIfBlank(
+                        pathFromSource.getDatabaseName(), 
pathFromDdl.getDatabaseName()),
+                StringUtils.defaultIfBlank(
+                        pathFromSource.getSchemaName(), 
pathFromDdl.getSchemaName()),
+                StringUtils.defaultIfBlank(
+                        pathFromSource.getTableName(), 
pathFromDdl.getTableName()));
+    }
+
+    private Table getCurrentTable(SourceRecord record, TablePath tablePath) {
+        Struct value = (Struct) record.value();
+        List<Struct> tableChangesStruct = 
value.getArray(HistoryRecord.Fields.TABLE_CHANGES);
+        TableChanges tableChanges = 
tableChangeSerializer.deserialize(tableChangesStruct, false);
+        for (TableChanges.TableChange tableChange : tableChanges) {
+            Table table = tableChange.getTable();
+            if (table == null) {
+                continue;
+            }
+            if (isSameIdentifier(table.id().catalog(), 
tablePath.getDatabaseName())
+                    && isSameIdentifier(table.id().schema(), 
tablePath.getSchemaName())
+                    && isSameIdentifier(table.id().table(), 
tablePath.getTableName())) {
+                return table;
+            }
+        }
+        return null;
+    }
+
+    private List<AlterTableColumnEvent> diffColumns(
+            CatalogTable currentCatalogTable,
+            Table currentTable,
+            Map<String, String> explicitRenames) {
+        List<Column> previousColumns = 
currentCatalogTable.getTableSchema().getColumns();
+        List<io.debezium.relational.Column> newColumns = 
currentTable.columns();
+        TableIdentifier tableIdentifier = currentCatalogTable.getTableId();
+
+        Map<String, Column> previousByName =
+                previousColumns.stream()
+                        .collect(
+                                Collectors.toMap(
+                                        column -> 
normalizeIdentifier(column.getName()),
+                                        column -> column,
+                                        (left, right) -> left,
+                                        LinkedHashMap::new));
+        Map<String, io.debezium.relational.Column> currentByName =
+                newColumns.stream()
+                        .collect(
+                                Collectors.toMap(
+                                        column -> 
normalizeIdentifier(column.name()),
+                                        column -> column,
+                                        (left, right) -> left,
+                                        LinkedHashMap::new));
+
+        Set<String> matchedNames = new HashSet<>(previousByName.keySet());
+        matchedNames.retainAll(currentByName.keySet());
+
+        List<AlterTableColumnEvent> events = new ArrayList<>();
+        for (int index = 0; index < newColumns.size(); index++) {
+            io.debezium.relational.Column newColumn = newColumns.get(index);
+            String normalizedName = normalizeIdentifier(newColumn.name());
+            if (!matchedNames.contains(normalizedName)) {
+                continue;
+            }
+            Column oldColumn = previousByName.get(normalizedName);
+            Column convertedColumn = convertColumn(newColumn);
+            boolean changed = hasColumnChanged(oldColumn, convertedColumn, 
index, previousColumns);
+            if (!changed) {
+                continue;
+            }
+            AlterTableModifyColumnEvent modifyEvent =
+                    buildModifyEvent(tableIdentifier, convertedColumn, index, 
newColumns);
+            modifyEvent.setTypeChanged(hasTypeChanged(oldColumn, 
convertedColumn));
+            modifyEvent.setSourceDialectName(SOURCE_DIALECT);
+            events.add(modifyEvent);
+        }
+
+        List<ColumnWithIndex<Column>> removedColumns = new ArrayList<>();
+        for (int index = 0; index < previousColumns.size(); index++) {
+            Column column = previousColumns.get(index);
+            if 
(!currentByName.containsKey(normalizeIdentifier(column.getName()))) {
+                removedColumns.add(new ColumnWithIndex<>(column, index));
+            }
+        }
+
+        List<ColumnWithIndex<io.debezium.relational.Column>> addedColumns = 
new ArrayList<>();
+        for (int index = 0; index < newColumns.size(); index++) {
+            io.debezium.relational.Column column = newColumns.get(index);
+            if 
(!previousByName.containsKey(normalizeIdentifier(column.name()))) {
+                addedColumns.add(new ColumnWithIndex<>(column, index));
+            }
+        }
+
+        pairRenameColumns(
+                events, tableIdentifier, newColumns, removedColumns, 
addedColumns, explicitRenames);
+
+        for (ColumnWithIndex<io.debezium.relational.Column> added : 
addedColumns) {
+            Column convertedColumn = convertColumn(added.value);
+            AlterTableAddColumnEvent addEvent =
+                    buildAddEvent(tableIdentifier, convertedColumn, 
added.index, newColumns);
+            addEvent.setSourceDialectName(SOURCE_DIALECT);
+            events.add(addEvent);
+        }
+
+        for (ColumnWithIndex<Column> removed : removedColumns) {
+            AlterTableDropColumnEvent dropEvent =
+                    new AlterTableDropColumnEvent(tableIdentifier, 
removed.value.getName());
+            dropEvent.setSourceDialectName(SOURCE_DIALECT);
+            events.add(dropEvent);
+        }
+
+        return events;
+    }
+
+    /** Extracts explicit SQL Server column renames declared through {@code 
sp_rename}. */
+    private Map<String, String> parseSpRenameColumns(String ddl) {
+        if (StringUtils.isBlank(ddl)) {
+            return Collections.emptyMap();
+        }
+        Map<String, String> renames = new LinkedHashMap<>();
+        Matcher matcher = SP_RENAME_COLUMN_PATTERN.matcher(ddl);
+        while (matcher.find()) {
+            String oldColumnName = extractLastIdentifierPart(matcher.group(1));
+            String newColumnName = extractLastIdentifierPart(matcher.group(2));
+            if (StringUtils.isBlank(oldColumnName) || 
StringUtils.isBlank(newColumnName)) {
+                continue;
+            }
+            renames.put(normalizeIdentifier(oldColumnName), 
normalizeIdentifier(newColumnName));
+        }
+        return renames;
+    }
+
+    private void pairRenameColumns(
+            List<AlterTableColumnEvent> events,
+            TableIdentifier tableIdentifier,
+            List<io.debezium.relational.Column> newColumns,
+            List<ColumnWithIndex<Column>> removedColumns,
+            List<ColumnWithIndex<io.debezium.relational.Column>> addedColumns,
+            Map<String, String> explicitRenames) {
+        Set<ColumnWithIndex<Column>> matchedRemoved = new HashSet<>();
+        Set<ColumnWithIndex<io.debezium.relational.Column>> matchedAdded = new 
HashSet<>();
+
+        for (ColumnWithIndex<io.debezium.relational.Column> added : 
addedColumns) {
+            String addedName = normalizeIdentifier(added.value.name());
+            Column convertedAdded = convertColumn(added.value);
+            ColumnWithIndex<Column> renameCandidate = null;
+            for (ColumnWithIndex<Column> removed : removedColumns) {
+                if (matchedRemoved.contains(removed)) {
+                    continue;
+                }
+                String removedName = 
normalizeIdentifier(removed.value.getName());
+                if (!StringUtils.equalsIgnoreCase(addedName, 
explicitRenames.get(removedName))) {
+                    continue;
+                }
+                if (!sameDefinitionExceptName(removed.value, convertedAdded)) {
+                    continue;
+                }
+                if (renameCandidate != null) {
+                    renameCandidate = null;
+                    break;
+                }
+                renameCandidate = removed;
+            }
+            if (renameCandidate == null) {
+                continue;
+            }
+            AlterTableChangeColumnEvent changeEvent =
+                    buildChangeEvent(
+                            tableIdentifier,
+                            renameCandidate.value.getName(),
+                            convertedAdded,
+                            added.index,
+                            newColumns);
+            changeEvent.setSourceDialectName(SOURCE_DIALECT);
+            events.add(changeEvent);
+            matchedRemoved.add(renameCandidate);
+            matchedAdded.add(added);
+        }
+
+        removedColumns.removeAll(matchedRemoved);
+        addedColumns.removeAll(matchedAdded);
+    }
+
+    private AlterTableAddColumnEvent buildAddEvent(
+            TableIdentifier tableIdentifier,
+            Column column,
+            int newIndex,
+            List<io.debezium.relational.Column> newColumns) {
+        if (newIndex == 0) {
+            return AlterTableAddColumnEvent.addFirst(tableIdentifier, column);
+        }
+        return AlterTableAddColumnEvent.addAfter(
+                tableIdentifier, column, newColumns.get(newIndex - 1).name());
+    }
+
+    private AlterTableModifyColumnEvent buildModifyEvent(
+            TableIdentifier tableIdentifier,
+            Column column,
+            int newIndex,
+            List<io.debezium.relational.Column> newColumns) {
+        if (newIndex == 0) {
+            return AlterTableModifyColumnEvent.modifyFirst(tableIdentifier, 
column);
+        }
+        return AlterTableModifyColumnEvent.modifyAfter(
+                tableIdentifier, column, newColumns.get(newIndex - 1).name());
+    }
+
+    private AlterTableChangeColumnEvent buildChangeEvent(
+            TableIdentifier tableIdentifier,
+            String oldColumnName,
+            Column newColumn,
+            int newIndex,
+            List<io.debezium.relational.Column> newColumns) {
+        if (newIndex == 0) {
+            return AlterTableChangeColumnEvent.changeFirst(
+                    tableIdentifier, oldColumnName, newColumn);
+        }
+        return AlterTableChangeColumnEvent.changeAfter(
+                tableIdentifier, oldColumnName, newColumn, 
newColumns.get(newIndex - 1).name());
+    }
+
+    private boolean hasColumnChanged(
+            Column oldColumn, Column newColumn, int newIndex, List<Column> 
previousColumns) {
+        if (oldColumn == null || newColumn == null) {
+            return true;
+        }
+        if (isSameColumnDefinition(oldColumn, newColumn)) {
+            int oldIndex = indexOfColumn(previousColumns, oldColumn.getName());
+            return oldIndex != newIndex;
+        }
+        return true;
+    }
+
+    private int indexOfColumn(List<Column> columns, String columnName) {
+        for (int index = 0; index < columns.size(); index++) {
+            if (isSameIdentifier(columns.get(index).getName(), columnName)) {
+                return index;
+            }
+        }
+        return -1;
+    }
+
+    private boolean hasTypeChanged(Column oldColumn, Column newColumn) {
+        return !Objects.equals(oldColumn.getDataType(), 
newColumn.getDataType())
+                || !isLengthCompatible(oldColumn.getColumnLength(), 
newColumn.getColumnLength())
+                || !isScaleCompatible(oldColumn.getScale(), 
newColumn.getScale())
+                || !isSourceTypeCompatible(oldColumn.getSourceType(), 
newColumn.getSourceType());
+    }
+
+    /**
+     * Returns {@code true} when the two columns share the same structural 
definition (type, length,
+     * scale, nullability, default, comment), ignoring the column name. Used 
to heuristically detect
+     * renames. Note: comment equality is included — a rename that also 
changes the comment will not
+     * be detected as a rename but as DROP + ADD.
+     */
+    private boolean sameDefinitionExceptName(Column oldColumn, Column 
newColumn) {
+        return isSameColumnDefinition(oldColumn, newColumn);
+    }
+
+    private Column convertColumn(io.debezium.relational.Column column) {
+        String sourceType = column.typeExpression();
+        if (StringUtils.isBlank(sourceType)) {
+            sourceType = column.typeName();
+        }
+        return PhysicalColumn.builder()
+                .name(column.name())
+                .dataType(SqlServerTypeUtils.convertFromColumn(column))
+                .columnLength(
+                        column.length() == 
io.debezium.relational.Column.UNSET_INT_VALUE
+                                ? null
+                                : (long) column.length())
+                .scale(column.scale().orElse(null))
+                .nullable(column.isOptional())
+                .defaultValue(column.defaultValueExpression().orElse(null))
+                .comment(column.comment())
+                .sourceType(sourceType)
+                .build();
+    }
+
+    private boolean isSameColumnDefinition(Column oldColumn, Column newColumn) 
{
+        return Objects.equals(oldColumn.getDataType(), newColumn.getDataType())
+                && isLengthCompatible(oldColumn.getColumnLength(), 
newColumn.getColumnLength())
+                && isScaleCompatible(oldColumn.getScale(), 
newColumn.getScale())
+                && Objects.equals(oldColumn.isNullable(), 
newColumn.isNullable())
+                && Objects.equals(oldColumn.getDefaultValue(), 
newColumn.getDefaultValue())
+                && Objects.equals(oldColumn.getComment(), 
newColumn.getComment())
+                && isSourceTypeCompatible(oldColumn.getSourceType(), 
newColumn.getSourceType());
+    }
+
+    private boolean isLengthCompatible(Long oldLength, Long newLength) {
+        return oldLength == null || newLength == null || 
Objects.equals(oldLength, newLength);
+    }
+
+    private boolean isScaleCompatible(Integer oldScale, Integer newScale) {
+        return oldScale == null || newScale == null || 
Objects.equals(oldScale, newScale);
+    }
+
+    private boolean isSourceTypeCompatible(String oldSourceType, String 
newSourceType) {
+        if (StringUtils.isBlank(oldSourceType) || 
StringUtils.isBlank(newSourceType)) {
+            return true;
+        }
+        String normalizedOld = oldSourceType.replace(" ", 
"").toLowerCase(Locale.ENGLISH);
+        String normalizedNew = newSourceType.replace(" ", 
"").toLowerCase(Locale.ENGLISH);
+        if (StringUtils.equals(normalizedOld, normalizedNew)) {
+            return true;
+        }
+        // When the CDC migration path reads column metadata via JDBC 
DatabaseMetaData, the
+        // typeExpression field may not be populated, causing convertColumn() 
to fall back to
+        // typeName() which lacks length/precision (e.g. "varchar" instead of 
"varchar(255)").
+        // Treat the types as compatible when their base names match and at 
least one side is
+        // missing the length qualifier — the actual column type has not 
changed in that case.
+        String baseOld =
+                normalizedOld.contains("(")
+                        ? normalizedOld.substring(0, 
normalizedOld.indexOf('('))
+                        : normalizedOld;
+        String baseNew =
+                normalizedNew.contains("(")
+                        ? normalizedNew.substring(0, 
normalizedNew.indexOf('('))
+                        : normalizedNew;
+        return StringUtils.equals(baseOld, baseNew)
+                && (!normalizedOld.contains("(") || 
!normalizedNew.contains("("));
+    }
+
+    private String extractLastIdentifierPart(String identifier) {
+        List<String> parts = parseIdentifierParts(identifier);
+        if (!parts.isEmpty()) {
+            return parts.get(parts.size() - 1);
+        }
+        return identifier;
+    }
+
+    private TablePath parseTablePathFromDdl(String ddl) {
+        if (StringUtils.isBlank(ddl)) {
+            return null;
+        }
+        Matcher matcher = ALTER_TABLE_PATTERN.matcher(ddl);
+        if (!matcher.find()) {
+            return null;
+        }
+        String tableIdentifier = matcher.group(1);
+        List<String> parts = parseIdentifierParts(tableIdentifier);
+        if (parts.isEmpty()) {
+            return null;
+        }
+        if (parts.size() == 1) {
+            return TablePath.of(null, null, parts.get(0));
+        }
+        if (parts.size() == 2) {
+            return TablePath.of(null, parts.get(0), parts.get(1));
+        }
+        return TablePath.of(
+                parts.get(parts.size() - 3),
+                parts.get(parts.size() - 2),
+                parts.get(parts.size() - 1));
+    }
+
+    private List<String> parseIdentifierParts(String rawIdentifier) {
+        if (StringUtils.isBlank(rawIdentifier)) {
+            return Collections.emptyList();
+        }
+        List<String> parts = new ArrayList<>();
+        Matcher matcher = TABLE_IDENTIFIER_PART_PATTERN.matcher(rawIdentifier);
+        while (matcher.find()) {
+            String part =
+                    firstNonBlank(
+                            matcher.group(1), matcher.group(2), 
matcher.group(3), matcher.group(4));
+            if (StringUtils.isNotBlank(part)) {
+                parts.add(part.trim());
+            }
+        }
+        return parts;
+    }
+
+    private String firstNonBlank(String... values) {
+        for (String value : values) {
+            if (StringUtils.isNotBlank(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
+
+    private boolean isSqlServerSchemaChangeEvent(SourceRecord record) {
+        if (record == null || record.keySchema() == null) {
+            return false;
+        }
+        String keySchemaName = record.keySchema().name();
+        return StringUtils.equalsIgnoreCase(keySchemaName, 
SQLSERVER_SCHEMA_CHANGE_KEY)
+                || SourceRecordUtils.isSchemaChangeEvent(record);
+    }
+
+    private boolean isSameTablePath(TablePath left, TablePath right) {
+        return left != null
+                && right != null
+                && isSameIdentifier(left.getDatabaseName(), 
right.getDatabaseName())
+                && isSameIdentifier(left.getSchemaName(), 
right.getSchemaName())
+                && isSameIdentifier(left.getTableName(), right.getTableName());
+    }
+
+    private boolean isSameIdentifier(String left, String right) {
+        String normalizedLeft = normalizeIdentifier(left);
+        String normalizedRight = normalizeIdentifier(right);
+        if (StringUtils.isBlank(normalizedLeft) || 
StringUtils.isBlank(normalizedRight)) {
+            return StringUtils.equals(normalizedLeft, normalizedRight);
+        }
+        return StringUtils.equalsIgnoreCase(normalizedLeft, normalizedRight);
+    }
+
+    private String normalizeIdentifier(String identifier) {
+        if (identifier == null) {
+            return null;
+        }
+        String normalized = identifier.trim();
+        if (StringUtils.startsWith(normalized, "[") && 
StringUtils.endsWith(normalized, "]")) {
+            normalized = normalized.substring(1, normalized.length() - 1);
+        }
+        if (StringUtils.startsWith(normalized, "\"") && 
StringUtils.endsWith(normalized, "\"")) {
+            normalized = normalized.substring(1, normalized.length() - 1);
+        }
+        if (StringUtils.startsWith(normalized, "`") && 
StringUtils.endsWith(normalized, "`")) {
+            normalized = normalized.substring(1, normalized.length() - 1);
+        }
+        return normalized;
+    }
+
+    private static class ColumnWithIndex<T> {
+
+        private final T value;
+        private final int index;
+
+        private ColumnWithIndex(T value, int index) {
+            this.value = value;
+            this.index = index;
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/io/debezium/connector/sqlserver/SqlServerConnectionTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/io/debezium/connector/sqlserver/SqlServerConnectionTest.java
index f74f545fd4..a9487185d9 100644
--- 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/io/debezium/connector/sqlserver/SqlServerConnectionTest.java
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/io/debezium/connector/sqlserver/SqlServerConnectionTest.java
@@ -30,6 +30,7 @@ import io.debezium.relational.Tables;
 
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Types;
@@ -37,6 +38,7 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.isNull;
 import static org.mockito.Mockito.mock;
@@ -58,13 +60,22 @@ public class SqlServerConnectionTest {
         when(columnsRs.getString("TABLE_NAME")).thenReturn("user_info", 
"userAinfo");
         when(columnsRs.getString("TABLE_SCHEM")).thenReturn("dbo", "dbo");
         when(columnsRs.getString("COLUMN_NAME")).thenReturn("id", "bad");
+        // doReadTableColumn reads column name and type by JDBC positional 
index, not by label
+        when(columnsRs.getString(4)).thenReturn("id", "bad"); // COLUMN_NAME
+        when(columnsRs.getString(6)).thenReturn("INT", "INT"); // TYPE_NAME
 
         DatabaseMetaData metadata = mock(DatabaseMetaData.class);
         when(metadata.getColumns(eq(databaseName), eq("dbo"), eq("user_info"), 
isNull()))
                 .thenReturn(columnsRs);
 
+        ResultSet emptyRs = mock(ResultSet.class);
+        when(emptyRs.next()).thenReturn(false);
+        PreparedStatement mockPs = mock(PreparedStatement.class);
+        when(mockPs.executeQuery()).thenReturn(emptyRs);
+
         Connection jdbcConnection = mock(Connection.class);
         when(jdbcConnection.getMetaData()).thenReturn(metadata);
+        when(jdbcConnection.prepareStatement(anyString())).thenReturn(mockPs);
 
         TestSqlServerConnection connection = new 
TestSqlServerConnection(jdbcConnection);
         Table table = connection.getTableSchemaFromTable(databaseName, 
changeTable);
@@ -93,8 +104,14 @@ public class SqlServerConnectionTest {
         when(metadata.getColumns(eq(databaseName), eq("dbo"), eq("UserInfo"), 
isNull()))
                 .thenReturn(columnsRs);
 
+        ResultSet emptyRs = mock(ResultSet.class);
+        when(emptyRs.next()).thenReturn(false);
+        PreparedStatement mockPs = mock(PreparedStatement.class);
+        when(mockPs.executeQuery()).thenReturn(emptyRs);
+
         Connection jdbcConnection = mock(Connection.class);
         when(jdbcConnection.getMetaData()).thenReturn(metadata);
+        when(jdbcConnection.prepareStatement(anyString())).thenReturn(mockPs);
 
         TestSqlServerConnection connection = new 
TestSqlServerConnection(jdbcConnection);
         Table table = connection.getTableSchemaFromTable(databaseName, 
changeTable);
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/config/SqlServerSourceConfigFactoryTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/config/SqlServerSourceConfigFactoryTest.java
new file mode 100644
index 0000000000..f4f4ad604b
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/config/SqlServerSourceConfigFactoryTest.java
@@ -0,0 +1,47 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.cdc.sqlserver.config;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class SqlServerSourceConfigFactoryTest {
+
+    @Test
+    void shouldEnableSchemaChangesInDebeziumConfigWhenSchemaEvolutionEnabled() 
{
+        SqlServerSourceConfigFactory configFactory = createFactory();
+        configFactory.schemaChangeEnabled(true);
+
+        SqlServerSourceConfig sourceConfig = configFactory.create(0);
+
+        Assertions.assertTrue(
+                sourceConfig
+                        .getDbzConfiguration()
+                        
.getBoolean(SqlServerSourceConfigFactory.SCHEMA_CHANGE_KEY));
+    }
+
+    private SqlServerSourceConfigFactory createFactory() {
+        return (SqlServerSourceConfigFactory)
+                new SqlServerSourceConfigFactory()
+                        .hostname("localhost")
+                        .port(1433)
+                        .username("sa")
+                        .password("Password!")
+                        .databaseList("schema_change_test");
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerSchemaChangeResolverTest.java
 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerSchemaChangeResolverTest.java
new file mode 100644
index 0000000000..b5d7be26cb
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-cdc/connector-cdc-sqlserver/src/test/java/org/apache/seatunnel/connectors/seatunnel/cdc/sqlserver/source/SqlServerSchemaChangeResolverTest.java
@@ -0,0 +1,299 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.cdc.sqlserver.source;
+
+import org.apache.seatunnel.api.event.EventType;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableChangeColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableColumnsEvent;
+import org.apache.seatunnel.api.table.schema.event.AlterTableDropColumnEvent;
+import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
+import org.apache.seatunnel.api.table.type.BasicType;
+import 
org.apache.seatunnel.connectors.cdc.debezium.ConnectTableChangeSerializer;
+
+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.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import io.debezium.connector.AbstractSourceInfo;
+import io.debezium.data.Envelope;
+import io.debezium.relational.Table;
+import io.debezium.relational.history.HistoryRecord;
+import io.debezium.relational.history.TableChanges;
+
+import java.sql.Types;
+import java.util.Arrays;
+import java.util.Collections;
+
+public class SqlServerSchemaChangeResolverTest {
+
+    private static final String DATABASE_NAME = "test_db";
+    private static final String SCHEMA_NAME = "dbo";
+    private static final String TABLE_NAME = "customers";
+    private static final TableIdentifier TABLE_IDENTIFIER =
+            TableIdentifier.of(null, DATABASE_NAME, SCHEMA_NAME, TABLE_NAME);
+    private static final Schema KEY_SCHEMA =
+            
SchemaBuilder.struct().name("io.debezium.connector.sqlserver.SchemaChangeKey").build();
+    private static final Schema SOURCE_SCHEMA =
+            SchemaBuilder.struct()
+                    .field(AbstractSourceInfo.DATABASE_NAME_KEY, 
Schema.STRING_SCHEMA)
+                    .field(AbstractSourceInfo.SCHEMA_NAME_KEY, 
Schema.STRING_SCHEMA)
+                    .field(AbstractSourceInfo.TABLE_NAME_KEY, 
Schema.STRING_SCHEMA)
+                    .build();
+    private static final Schema VALUE_SCHEMA =
+            SchemaBuilder.struct()
+                    .field(Envelope.FieldName.SOURCE, SOURCE_SCHEMA)
+                    .field(HistoryRecord.Fields.DDL_STATEMENTS, 
Schema.OPTIONAL_STRING_SCHEMA)
+                    .field(
+                            HistoryRecord.Fields.TABLE_CHANGES,
+                            
SchemaBuilder.array(ConnectTableChangeSerializer.CHANGE_SCHEMA).build())
+                    .build();
+
+    private final SqlServerSchemaChangeResolver resolver = new 
SqlServerSchemaChangeResolver();
+    private final ConnectTableChangeSerializer serializer = new 
ConnectTableChangeSerializer();
+
+    @Test
+    void shouldRecognizeSqlServerSchemaChangeRecord() {
+        SourceRecord record = 
createRecord(createAlterTableChange(varcharColumn("email", 3, 128)));
+
+        Assertions.assertTrue(resolver.support(record));
+    }
+
+    @Test
+    void shouldResolveAddColumnEventFromTableChanges() {
+        SourceRecord record = 
createRecord(createAlterTableChange(varcharColumn("email", 3, 128)));
+
+        SchemaChangeEvent event =
+                resolver.resolve(record, 
Collections.singletonList(createCatalogTable()));
+
+        Assertions.assertInstanceOf(AlterTableColumnsEvent.class, event);
+        AlterTableColumnsEvent columnsEvent = (AlterTableColumnsEvent) event;
+        Assertions.assertEquals(
+                EventType.SCHEMA_CHANGE_UPDATE_COLUMNS, 
columnsEvent.getEventType());
+        Assertions.assertEquals(1, columnsEvent.getEvents().size());
+        Assertions.assertInstanceOf(
+                AlterTableAddColumnEvent.class, 
columnsEvent.getEvents().get(0));
+        AlterTableAddColumnEvent addColumnEvent =
+                (AlterTableAddColumnEvent) columnsEvent.getEvents().get(0);
+        Assertions.assertEquals("email", addColumnEvent.getColumn().getName());
+        Assertions.assertEquals("name", addColumnEvent.getAfterColumn());
+    }
+
+    @Test
+    void shouldResolveRenameColumnEventFromTableChanges() {
+        TableChanges tableChanges = new TableChanges();
+        tableChanges.alter(
+                Table.editor()
+                        .tableId(
+                                new io.debezium.relational.TableId(
+                                        DATABASE_NAME, SCHEMA_NAME, 
TABLE_NAME))
+                        .setPrimaryKeyNames(Collections.singletonList("id"))
+                        .setColumns(
+                                Arrays.asList(
+                                        intColumn("id", 1), 
varcharColumn("full_name", 2, 64)))
+                        .create());
+        SourceRecord record =
+                createRecord(
+                        tableChanges,
+                        DATABASE_NAME,
+                        SCHEMA_NAME,
+                        TABLE_NAME,
+                        "EXEC sp_rename 'dbo.customers.name', 'full_name', 
'COLUMN'");
+
+        SchemaChangeEvent event =
+                resolver.resolve(record, 
Collections.singletonList(createCatalogTable()));
+
+        Assertions.assertInstanceOf(AlterTableColumnsEvent.class, event);
+        AlterTableColumnsEvent columnsEvent = (AlterTableColumnsEvent) event;
+        Assertions.assertEquals(1, columnsEvent.getEvents().size());
+        Assertions.assertInstanceOf(
+                AlterTableChangeColumnEvent.class, 
columnsEvent.getEvents().get(0));
+        AlterTableChangeColumnEvent changeEvent =
+                (AlterTableChangeColumnEvent) columnsEvent.getEvents().get(0);
+        Assertions.assertEquals("name", changeEvent.getOldColumn());
+        Assertions.assertEquals("full_name", 
changeEvent.getColumn().getName());
+        Assertions.assertEquals("id", changeEvent.getAfterColumn());
+    }
+
+    @Test
+    void shouldEmitDropAndAddWhenDdlIsNotExplicitRename() {
+        TableChanges tableChanges = new TableChanges();
+        tableChanges.alter(
+                Table.editor()
+                        .tableId(
+                                new io.debezium.relational.TableId(
+                                        DATABASE_NAME, SCHEMA_NAME, 
TABLE_NAME))
+                        .setPrimaryKeyNames(Collections.singletonList("id"))
+                        .setColumns(
+                                Arrays.asList(
+                                        intColumn("id", 1), 
varcharColumn("full_name", 2, 64)))
+                        .create());
+        SourceRecord record =
+                createRecord(
+                        tableChanges,
+                        DATABASE_NAME,
+                        SCHEMA_NAME,
+                        TABLE_NAME,
+                        "ALTER TABLE dbo.customers DROP COLUMN name; ALTER 
TABLE dbo.customers ADD full_name VARCHAR(64)");
+
+        SchemaChangeEvent event =
+                resolver.resolve(record, 
Collections.singletonList(createCatalogTable()));
+
+        Assertions.assertInstanceOf(AlterTableColumnsEvent.class, event);
+        AlterTableColumnsEvent columnsEvent = (AlterTableColumnsEvent) event;
+        Assertions.assertEquals(2, columnsEvent.getEvents().size());
+        Assertions.assertInstanceOf(
+                AlterTableAddColumnEvent.class, 
columnsEvent.getEvents().get(0));
+        Assertions.assertInstanceOf(
+                AlterTableDropColumnEvent.class, 
columnsEvent.getEvents().get(1));
+
+        AlterTableAddColumnEvent addEvent =
+                (AlterTableAddColumnEvent) columnsEvent.getEvents().get(0);
+        Assertions.assertEquals("full_name", addEvent.getColumn().getName());
+        Assertions.assertEquals("id", addEvent.getAfterColumn());
+
+        AlterTableDropColumnEvent dropEvent =
+                (AlterTableDropColumnEvent) columnsEvent.getEvents().get(1);
+        Assertions.assertEquals("name", dropEvent.getColumn());
+    }
+
+    @Test
+    void shouldResolveSchemaChangeWhenIdentifiersAreBracketed() {
+        SourceRecord record =
+                createRecord(
+                        createAlterTableChange(varcharColumn("email", 3, 128)),
+                        "[test_db]",
+                        "[dbo]",
+                        "[customers]",
+                        "ALTER TABLE [test_db].[dbo].[customers] ADD [email] 
VARCHAR(128)");
+
+        SchemaChangeEvent event =
+                resolver.resolve(record, 
Collections.singletonList(createCatalogTable()));
+
+        Assertions.assertInstanceOf(AlterTableColumnsEvent.class, event);
+        AlterTableColumnsEvent columnsEvent = (AlterTableColumnsEvent) event;
+        Assertions.assertEquals(1, columnsEvent.getEvents().size());
+        Assertions.assertInstanceOf(
+                AlterTableAddColumnEvent.class, 
columnsEvent.getEvents().get(0));
+    }
+
+    private CatalogTable createCatalogTable() {
+        return CatalogTable.of(
+                TABLE_IDENTIFIER,
+                TableSchema.builder()
+                        .column(
+                                PhysicalColumn.builder()
+                                        .name("id")
+                                        .dataType(BasicType.INT_TYPE)
+                                        .nullable(false)
+                                        .columnLength(10L)
+                                        .sourceType("int")
+                                        .build())
+                        .column(
+                                PhysicalColumn.builder()
+                                        .name("name")
+                                        .dataType(BasicType.STRING_TYPE)
+                                        .nullable(true)
+                                        .columnLength(64L)
+                                        .sourceType("varchar(64)")
+                                        .build())
+                        .build(),
+                Collections.emptyMap(),
+                Collections.emptyList(),
+                null,
+                null);
+    }
+
+    private TableChanges createAlterTableChange(io.debezium.relational.Column 
newColumn) {
+        TableChanges tableChanges = new TableChanges();
+        tableChanges.alter(
+                Table.editor()
+                        .tableId(
+                                new io.debezium.relational.TableId(
+                                        DATABASE_NAME, SCHEMA_NAME, 
TABLE_NAME))
+                        .setPrimaryKeyNames(Collections.singletonList("id"))
+                        .setColumns(
+                                Arrays.asList(
+                                        intColumn("id", 1),
+                                        varcharColumn("name", 2, 64),
+                                        newColumn))
+                        .create());
+        return tableChanges;
+    }
+
+    private SourceRecord createRecord(TableChanges tableChanges) {
+        return createRecord(tableChanges, DATABASE_NAME, SCHEMA_NAME, 
TABLE_NAME, "ALTER TABLE");
+    }
+
+    private SourceRecord createRecord(
+            TableChanges tableChanges,
+            String databaseName,
+            String schemaName,
+            String tableName,
+            String ddl) {
+        Struct value = new Struct(VALUE_SCHEMA);
+        value.put(
+                Envelope.FieldName.SOURCE,
+                new Struct(SOURCE_SCHEMA)
+                        .put(AbstractSourceInfo.DATABASE_NAME_KEY, 
databaseName)
+                        .put(AbstractSourceInfo.SCHEMA_NAME_KEY, schemaName)
+                        .put(AbstractSourceInfo.TABLE_NAME_KEY, tableName));
+        value.put(HistoryRecord.Fields.DDL_STATEMENTS, ddl);
+        value.put(HistoryRecord.Fields.TABLE_CHANGES, 
serializer.serialize(tableChanges));
+        return new SourceRecord(
+                Collections.emptyMap(),
+                Collections.emptyMap(),
+                "topic",
+                null,
+                KEY_SCHEMA,
+                new Struct(KEY_SCHEMA),
+                VALUE_SCHEMA,
+                value);
+    }
+
+    private io.debezium.relational.Column intColumn(String name, int position) 
{
+        return io.debezium.relational.Column.editor()
+                .name(name)
+                .jdbcType(Types.INTEGER)
+                .nativeType(Types.INTEGER)
+                .type("int", "int")
+                .position(position)
+                .optional(false)
+                .create();
+    }
+
+    private io.debezium.relational.Column varcharColumn(String name, int 
position, int length) {
+        return io.debezium.relational.Column.editor()
+                .name(name)
+                .jdbcType(Types.VARCHAR)
+                .nativeType(Types.VARCHAR)
+                .type("varchar", "varchar(" + length + ")")
+                .length(length)
+                .position(position)
+                .optional(true)
+                .create();
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/sqlserver/SqlServerDialect.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/sqlserver/SqlServerDialect.java
index 7581f52f24..6645ea424c 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/sqlserver/SqlServerDialect.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/sqlserver/SqlServerDialect.java
@@ -292,7 +292,7 @@ public class SqlServerDialect implements JdbcDialect {
         String sourceDialectName = event.getSourceDialectName();
         boolean sameCatalog = StringUtils.equals(dialectName(), 
sourceDialectName);
         BasicTypeDefine typeDefine = getTypeConverter().reconvert(column);
-        String columnType = sameCatalog ? column.getSourceType() : 
typeDefine.getColumnType();
+        String columnType = resolveColumnType(sameCatalog, column, typeDefine);
 
         // Build the SQL statement that add the column
         StringBuilder sqlBuilder =
@@ -310,8 +310,22 @@ public class SqlServerDialect implements JdbcDialect {
         }
 
         if (!column.isNullable()) {
-            // Handle null constraints
-            sqlBuilder.append(" NOT NULL");
+            if (column.getDefaultValue() != null) {
+                // A DEFAULT is present — SQL Server can populate existing 
rows, so NOT NULL is
+                // safe.
+                sqlBuilder.append(" NOT NULL");
+            } else {
+                // SQL Server forbids adding a NOT NULL column without a 
DEFAULT to a non-empty
+                // table.
+                // Add as NULL so that existing rows are not affected; 
subsequent CDC UPDATE events
+                // will fill in the actual values for those rows.
+                log.warn(
+                        "Column '{}' in table {} is NOT NULL but has no 
DEFAULT; adding as NULL "
+                                + "to allow addition to a non-empty table.",
+                        column.getName(),
+                        tablePath.getFullName());
+                sqlBuilder.append(" NULL");
+            }
         }
 
         ddlSQL.add(sqlBuilder.toString());
@@ -331,16 +345,13 @@ public class SqlServerDialect implements JdbcDialect {
         List<String> ddlSQL = new ArrayList<>();
         if (event.getOldColumn() != null
                 && 
!(event.getColumn().getName().equals(event.getOldColumn()))) {
+            String renameObject = buildRenameColumnObject(tablePath, 
event.getOldColumn());
             StringBuilder sqlBuilder =
                     new StringBuilder()
-                            .append("EXEC sp_rename ")
-                            .append(
-                                    String.format(
-                                            "'%s.%s.%s.%s', ",
-                                            tablePath.getDatabaseName(),
-                                            tablePath.getSchemaName(),
-                                            tablePath.getTableName(),
-                                            event.getOldColumn()))
+                            .append("EXEC ")
+                            .append(buildRenameProcedure(tablePath))
+                            .append(" ")
+                            .append(String.format("'%s', ", renameObject))
                             .append(String.format("'%s', 'COLUMN';", 
event.getColumn().getName()));
             ddlSQL.add(sqlBuilder.toString());
         }
@@ -363,7 +374,7 @@ public class SqlServerDialect implements JdbcDialect {
         String sourceDialectName = event.getSourceDialectName();
         boolean sameCatalog = StringUtils.equals(dialectName(), 
sourceDialectName);
         BasicTypeDefine typeDefine = getTypeConverter().reconvert(column);
-        String columnType = sameCatalog ? column.getSourceType() : 
typeDefine.getColumnType();
+        String columnType = resolveColumnType(sameCatalog, column, typeDefine);
         List<String> ddlSQL = new ArrayList<>();
         // Handle field default constraints.
         if (column.getDefaultValue() != null) {
@@ -521,17 +532,57 @@ public class SqlServerDialect implements JdbcDialect {
 
     private boolean columnIsNullable(Connection connection, TablePath 
tablePath, String column)
             throws SQLException {
+        // Prefix with the target database name so the query works even when 
the JDBC connection
+        // is connected to a different database (e.g. master with no 
databaseName in the URL).
+        String databaseName = tablePath.getDatabaseName();
+        String infoSchemaPrefix =
+                StringUtils.isNotBlank(databaseName)
+                        ? quoteDatabaseIdentifier(databaseName) + 
".INFORMATION_SCHEMA"
+                        : "INFORMATION_SCHEMA";
         String selectColumnSQL =
                 String.format(
-                        "SELECT IS_NULLABLE FROM information_schema.COLUMNS 
WHERE %s AND COLUMN_NAME = '%s';",
-                        buildCommonWhereClause(tablePath), column);
+                        "SELECT IS_NULLABLE FROM %s.COLUMNS WHERE %s AND 
COLUMN_NAME = '%s';",
+                        infoSchemaPrefix, buildCommonWhereClause(tablePath), 
column);
         try (Statement statement = connection.createStatement()) {
             ResultSet rs = statement.executeQuery(selectColumnSQL);
-            rs.next();
+            if (!rs.next()) {
+                // Column not found — default to non-nullable to avoid 
incorrectly appending
+                // NULL to an ALTER COLUMN statement for a column that doesn't 
allow nulls.
+                log.warn(
+                        "Column '{}' not found in {}.COLUMNS for table {}; 
assuming NOT NULL",
+                        column,
+                        infoSchemaPrefix,
+                        tablePath.getFullName());
+                return false;
+            }
             return rs.getString("IS_NULLABLE").equals("YES");
         }
     }
 
+    /**
+     * Returns the SQL column type string to use in DDL statements.
+     *
+     * <p>When source and sink are the same catalog (SQL Server to SQL Server) 
we prefer the
+     * original {@code sourceType} because it already carries the full type 
expression (e.g. {@code
+     * varchar(255)}). However, when the CDC schema-change path produces a 
column whose {@code
+     * sourceType} is a bare type name without length/precision (e.g. {@code 
varchar} instead of
+     * {@code varchar(255)}), SQL Server rejects the resulting DDL statement. 
In that case we fall
+     * back to {@code typeDefine.getColumnType()}, which is always fully 
qualified.
+     */
+    private String resolveColumnType(
+            boolean sameCatalog, Column column, BasicTypeDefine typeDefine) {
+        if (!sameCatalog) {
+            return typeDefine.getColumnType();
+        }
+        String sourceType = column.getSourceType();
+        if (StringUtils.isBlank(sourceType) || !sourceType.contains("(")) {
+            // Bare type name (no length/precision); use the fully-qualified 
type from
+            // the type converter to avoid SQL Server DDL syntax errors.
+            return typeDefine.getColumnType();
+        }
+        return sourceType;
+    }
+
     private StringBuilder buildAlterTablePrefix(TablePath tablePath) {
         return new StringBuilder("ALTER TABLE 
").append(tableIdentifier(tablePath));
     }
@@ -541,4 +592,21 @@ public class SqlServerDialect implements JdbcDialect {
                 "TABLE_CATALOG = '%s' AND TABLE_SCHEMA = '%s' AND TABLE_NAME = 
'%s'",
                 tablePath.getDatabaseName(), tablePath.getSchemaName(), 
tablePath.getTableName());
     }
+
+    private String buildRenameColumnObject(TablePath tablePath, String 
oldColumn) {
+        List<String> objectParts = new ArrayList<>();
+        if (StringUtils.isNotBlank(tablePath.getSchemaName())) {
+            objectParts.add(tablePath.getSchemaName());
+        }
+        objectParts.add(tablePath.getTableName());
+        objectParts.add(oldColumn);
+        return String.join(".", objectParts);
+    }
+
+    private String buildRenameProcedure(TablePath tablePath) {
+        if (StringUtils.isNotBlank(tablePath.getDatabaseName())) {
+            return quoteDatabaseIdentifier(tablePath.getDatabaseName()) + 
".sys.sp_rename";
+        }
+        return "sp_rename";
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/sqlserver/SqlServerDialectTest.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/sqlserver/SqlServerDialectTest.java
new file mode 100644
index 0000000000..f10d828652
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/dialect/sqlserver/SqlServerDialectTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.jdbc.internal.dialect.sqlserver;
+
+import org.apache.seatunnel.api.table.catalog.Column;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TablePath;
+import org.apache.seatunnel.api.table.schema.event.AlterTableChangeColumnEvent;
+
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.Statement;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class SqlServerDialectTest {
+
+    @Test
+    void shouldUseSchemaTableColumnForRenameWithoutDatabasePrefix() throws 
Exception {
+        SqlServerDialect dialect = new SqlServerDialect();
+        Connection connection = mock(Connection.class);
+        Statement statement = mock(Statement.class);
+        when(connection.createStatement()).thenReturn(statement);
+
+        Column newColumn = mock(Column.class);
+        when(newColumn.getName()).thenReturn("add_column");
+        when(newColumn.getDataType()).thenReturn(null);
+
+        AlterTableChangeColumnEvent event =
+                AlterTableChangeColumnEvent.change(
+                        TableIdentifier.of("SqlServer", "schema_change_test", 
"dbo", "products"),
+                        "add_column2",
+                        newColumn);
+
+        dialect.applySchemaChange(
+                connection, TablePath.of("schema_change_test", "dbo", 
"products_sink"), event);
+
+        verify(statement)
+                .execute(
+                        "EXEC [schema_change_test].sys.sp_rename 
'dbo.products_sink.add_column2', 'add_column', 'COLUMN';");
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java
index b70f60843b..8df8f19a8f 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/java/org/apache/seatunnel/e2e/connector/cdc/sqlserver/SqlServerCDCIT.java
@@ -88,6 +88,10 @@ public class SqlServerCDCIT extends TestSuiteBase implements 
TestResource {
 
     public static final String DATABASE_NAME = "column_type_test";
     public static final String SCHEMA_NAME = "dbo";
+    public static final String SCHEMA_EVOLUTION_DATABASE_NAME = 
"schema_change_test";
+    private static final String SCHEMA_EVOLUTION_SOURCE_TABLE = "products";
+    private static final String SCHEMA_EVOLUTION_SINK_TABLE = "products_sink";
+    private static final int INCREMENTAL_MARKER_ID = 1000;
 
     private static final String DISABLE_DB_CDC =
             "IF EXISTS(select 1 from sys.databases where name='#' AND 
is_cdc_enabled=1)\n"
@@ -161,6 +165,16 @@ public class SqlServerCDCIT extends TestSuiteBase 
implements TestResource {
                     + "  CONVERT(varchar(100), val_varbinary) as 
val_varbinary,\n"
                     + "  val_udtdecimal\n"
                     + "from %s order by id asc";
+    private static final String SELECT_SCHEMA_EVOLUTION_DATA_SQL =
+            "SELECT * FROM %s ORDER BY id ASC";
+    private static final String SELECT_SCHEMA_EVOLUTION_COLUMNS_SQL =
+            "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, "
+                    + "COALESCE(CAST(CHARACTER_MAXIMUM_LENGTH AS VARCHAR(20)), 
'null'), "
+                    + "COALESCE(CAST(NUMERIC_PRECISION AS VARCHAR(20)), 
'null'), "
+                    + "COALESCE(CAST(NUMERIC_SCALE AS VARCHAR(20)), 'null') "
+                    + "FROM INFORMATION_SCHEMA.COLUMNS "
+                    + "WHERE TABLE_CATALOG = '%s' AND TABLE_SCHEMA = '%s' AND 
TABLE_NAME = '%s' "
+                    + "ORDER BY ORDINAL_POSITION";
 
     public static final MSSQLServerContainer MSSQL_SERVER_CONTAINER =
             new 
MSSQLServerContainer<>("mcr.microsoft.com/mssql/server:2019-latest")
@@ -505,6 +519,50 @@ public class SqlServerCDCIT extends TestSuiteBase 
implements TestResource {
      * Executes a JDBC statement using the default jdbc config without 
autocommitting the
      * connection.
      */
+    private void assertSchemaEvolutionTableStructureAndData(
+            String databaseName, String sourceTable, String sinkTable) {
+        String sourceTablePath = databaseName + "." + SCHEMA_NAME + "." + 
sourceTable;
+        String sinkTablePath = databaseName + "." + SCHEMA_NAME + "." + 
sinkTable;
+        await().atMost(120000, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () ->
+                                Assertions.assertIterableEquals(
+                                        
querySql(SELECT_SCHEMA_EVOLUTION_DATA_SQL, sourceTablePath),
+                                        
querySql(SELECT_SCHEMA_EVOLUTION_DATA_SQL, sinkTablePath)));
+        await().atMost(120000, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () ->
+                                Assertions.assertIterableEquals(
+                                        querySql(
+                                                String.format(
+                                                        
SELECT_SCHEMA_EVOLUTION_COLUMNS_SQL,
+                                                        databaseName,
+                                                        SCHEMA_NAME,
+                                                        sourceTable)),
+                                        querySql(
+                                                String.format(
+                                                        
SELECT_SCHEMA_EVOLUTION_COLUMNS_SQL,
+                                                        databaseName,
+                                                        SCHEMA_NAME,
+                                                        sinkTable))));
+    }
+
+    private void executeSqlFile(String sqlFile) {
+        final String ddlFile = String.format("ddl/%s.sql", sqlFile);
+        final URL ddlTestFile = 
TestSuiteBase.class.getClassLoader().getResource(ddlFile);
+        Assertions.assertNotNull(ddlTestFile, "Cannot locate " + ddlFile);
+        try (Connection connection = getJdbcConnection();
+                Statement statement = connection.createStatement()) {
+            List<String> statements =
+                    
parseStatements(Files.readAllLines(Paths.get(ddlTestFile.toURI())));
+            for (String stmt : statements) {
+                statement.execute(stmt);
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
     private void initializeSqlServerTable(String sqlFile) {
         final String ddlFile = String.format("ddl/%s.sql", sqlFile);
         final URL ddlTestFile = 
TestSuiteBase.class.getClassLoader().getResource(ddlFile);
@@ -515,20 +573,7 @@ public class SqlServerCDCIT extends TestSuiteBase 
implements TestResource {
             String ddlContent = String.join("\n", ddlLines);
             String actualDatabaseName = extractDatabaseName(ddlContent);
             dropTestDatabase(connection, actualDatabaseName);
-            final List<String> statements =
-                    Arrays.stream(
-                                    ddlLines.stream()
-                                            .map(String::trim)
-                                            .filter(x -> !x.startsWith("--") 
&& !x.isEmpty())
-                                            .map(
-                                                    x -> {
-                                                        final Matcher m =
-                                                                
COMMENT_PATTERN.matcher(x);
-                                                        return m.matches() ? 
m.group(1) : x;
-                                                    })
-                                            .collect(Collectors.joining("\n"))
-                                            .split(";"))
-                            .collect(Collectors.toList());
+            final List<String> statements = parseStatements(ddlLines);
             for (String stmt : statements) {
                 statement.execute(stmt);
             }
@@ -537,6 +582,23 @@ public class SqlServerCDCIT extends TestSuiteBase 
implements TestResource {
         }
     }
 
+    private List<String> parseStatements(List<String> ddlLines) {
+        return Arrays.stream(
+                        ddlLines.stream()
+                                .map(String::trim)
+                                .filter(x -> !x.startsWith("--") && 
!x.isEmpty())
+                                .map(
+                                        x -> {
+                                            final Matcher m = 
COMMENT_PATTERN.matcher(x);
+                                            return m.matches() ? m.group(1) : 
x;
+                                        })
+                                .collect(Collectors.joining("\n"))
+                                .split(";"))
+                .map(String::trim)
+                .filter(x -> !x.isEmpty())
+                .collect(Collectors.toList());
+    }
+
     private String extractDatabaseName(String ddlContent) {
         Pattern createDbPattern =
                 Pattern.compile(
@@ -597,8 +659,9 @@ public class SqlServerCDCIT extends TestSuiteBase 
implements TestResource {
     }
 
     private void executeSql(String sql) {
-        try (Connection connection = getJdbcConnection()) {
-            connection.createStatement().execute(sql);
+        try (Connection connection = getJdbcConnection();
+                Statement statement = connection.createStatement()) {
+            statement.execute(sql);
         } catch (SQLException e) {
             throw new RuntimeException(e);
         }
@@ -719,6 +782,94 @@ public class SqlServerCDCIT extends TestSuiteBase 
implements TestResource {
                         });
     }
 
+    @TestTemplate
+    @DisabledOnContainer(
+            value = {},
+            type = {EngineType.SPARK},
+            disabledReason =
+                    "This case validates SqlServer CDC schema evolution on the 
Flink engine & zeta engine.")
+    public void testWithSchemaEvolution(TestContainer container) {
+        initializeSqlServerTable("schema_change_test");
+
+        CompletableFuture.supplyAsync(
+                () -> {
+                    try {
+                        
container.executeJob("/sqlservercdc_to_sqlserver_with_schema_change.conf");
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                    return null;
+                });
+
+        assertSchemaEvolutionTableStructureAndData(
+                SCHEMA_EVOLUTION_DATABASE_NAME,
+                SCHEMA_EVOLUTION_SOURCE_TABLE,
+                SCHEMA_EVOLUTION_SINK_TABLE);
+
+        waitForSchemaEvolutionIncrementalStarted();
+
+        executeSqlFile("sqlserver_schema_change_add_columns");
+        assertSchemaEvolutionTableStructureAndData(
+                SCHEMA_EVOLUTION_DATABASE_NAME,
+                SCHEMA_EVOLUTION_SOURCE_TABLE,
+                SCHEMA_EVOLUTION_SINK_TABLE);
+
+        executeSqlFile("sqlserver_schema_change_drop_columns");
+        assertSchemaEvolutionTableStructureAndData(
+                SCHEMA_EVOLUTION_DATABASE_NAME,
+                SCHEMA_EVOLUTION_SOURCE_TABLE,
+                SCHEMA_EVOLUTION_SINK_TABLE);
+
+        executeSqlFile("sqlserver_schema_change_rename_columns");
+        assertSchemaEvolutionTableStructureAndData(
+                SCHEMA_EVOLUTION_DATABASE_NAME,
+                SCHEMA_EVOLUTION_SOURCE_TABLE,
+                SCHEMA_EVOLUTION_SINK_TABLE);
+
+        executeSqlFile("sqlserver_schema_change_modify_columns");
+        assertSchemaEvolutionTableStructureAndData(
+                SCHEMA_EVOLUTION_DATABASE_NAME,
+                SCHEMA_EVOLUTION_SOURCE_TABLE,
+                SCHEMA_EVOLUTION_SINK_TABLE);
+    }
+
+    private void waitForSchemaEvolutionIncrementalStarted() {
+        String sourceTablePath =
+                SCHEMA_EVOLUTION_DATABASE_NAME
+                        + "."
+                        + SCHEMA_NAME
+                        + "."
+                        + SCHEMA_EVOLUTION_SOURCE_TABLE;
+        String sinkTablePath =
+                SCHEMA_EVOLUTION_DATABASE_NAME
+                        + "."
+                        + SCHEMA_NAME
+                        + "."
+                        + SCHEMA_EVOLUTION_SINK_TABLE;
+        executeSql(
+                String.format(
+                        "INSERT INTO %s VALUES (%d, 'incremental-marker', 
'ensure-stream-phase', 9.9)",
+                        sourceTablePath, INCREMENTAL_MARKER_ID));
+        await().atMost(60000, TimeUnit.MILLISECONDS)
+                .untilAsserted(
+                        () ->
+                                Assertions.assertEquals(
+                                        querySql(
+                                                        String.format(
+                                                                "SELECT 
COUNT(1) FROM %s WHERE id = %d",
+                                                                
sourceTablePath,
+                                                                
INCREMENTAL_MARKER_ID))
+                                                .get(0)
+                                                .get(0),
+                                        querySql(
+                                                        String.format(
+                                                                "SELECT 
COUNT(1) FROM %s WHERE id = %d",
+                                                                sinkTablePath,
+                                                                
INCREMENTAL_MARKER_ID))
+                                                .get(0)
+                                                .get(0)));
+    }
+
     @TestTemplate
     @DisabledOnContainer(
             value = {},
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/schema_change_test.sql
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/schema_change_test.sql
new file mode 100644
index 0000000000..dfbe560d18
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/schema_change_test.sql
@@ -0,0 +1,47 @@
+--
+-- 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 DATABASE schema_change_test;
+USE schema_change_test;
+EXEC sys.sp_cdc_enable_db;
+
+CREATE TABLE dbo.products (
+    id INT NOT NULL,
+    name VARCHAR(255) NOT NULL,
+    description VARCHAR(512),
+    weight FLOAT,
+    PRIMARY KEY (id)
+);
+
+CREATE TABLE dbo.products_sink (
+    id INT NOT NULL,
+    name VARCHAR(255) NOT NULL,
+    description VARCHAR(512),
+    weight FLOAT,
+    PRIMARY KEY (id)
+);
+
+INSERT INTO dbo.products VALUES (101, 'scooter', 'Small 2-wheel scooter', 
3.14);
+INSERT INTO dbo.products VALUES (102, 'car battery', '12V car battery', 8.1);
+INSERT INTO dbo.products VALUES (103, '12-pack drill bits', '12-pack of drill 
bits with sizes ranging from #40 to #3', 0.8);
+INSERT INTO dbo.products VALUES (104, 'hammer', '12oz carpenter''s hammer', 
0.75);
+INSERT INTO dbo.products VALUES (105, 'hammer', '14oz carpenter''s hammer', 
0.875);
+INSERT INTO dbo.products VALUES (106, 'hammer', '16oz carpenter''s hammer', 
1.0);
+INSERT INTO dbo.products VALUES (107, 'rocks', 'box of assorted rocks', 5.3);
+INSERT INTO dbo.products VALUES (108, 'jacket', 'water resistant black wind 
breaker', 0.1);
+INSERT INTO dbo.products VALUES (109, 'spare tire', '24 inch spare tire', 
22.2);
+
+EXEC sys.sp_cdc_enable_table @source_schema = 'dbo', @source_name = 
'products', @role_name = NULL, @supports_net_changes = 0;
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_add_columns.sql
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_add_columns.sql
new file mode 100644
index 0000000000..628c6e444d
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_add_columns.sql
@@ -0,0 +1,55 @@
+--
+-- 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.
+
+USE schema_change_test;
+
+INSERT INTO dbo.products VALUES (110, 'scooter', 'Small 2-wheel scooter', 
3.14);
+UPDATE dbo.products SET name = 'updated-before-add' WHERE id = 101;
+DELETE FROM dbo.products WHERE id = 102;
+
+-- Add columns as NULL first so that existing rows are not silently filled by 
SQL Server's
+-- DEFAULT mechanism (which does not generate CDC events). We will explicitly 
set values
+-- for all pre-existing rows via UPDATE so that CDC can replicate them to the 
sink.
+ALTER TABLE dbo.products
+ADD add_column1 VARCHAR(64) NULL,
+    add_column2 INT NULL;
+
+INSERT INTO dbo.products VALUES (120, 'car battery', '12V car battery', 8.1, 
'xx', 2);
+INSERT INTO dbo.products VALUES (121, 'drill bits', 'sizes from #40 to #3', 
0.8, 'xx', 3);
+
+ALTER TABLE dbo.products
+ADD add_column3 FLOAT NULL;
+
+ALTER TABLE dbo.products
+ADD add_column4 DATETIME2 NULL;
+
+EXEC sys.sp_cdc_enable_table
+    @source_schema = 'dbo',
+    @source_name = 'products',
+    @role_name = NULL,
+    @capture_instance = 'dbo_products_v2',
+    @supports_net_changes = 0;
+
+-- Backfill the newly added columns only after the new capture instance is 
enabled,
+-- otherwise the source table is updated but the sink never receives the 
non-null values.
+UPDATE dbo.products SET add_column1 = 'yy', add_column2 = 1
+WHERE id IN (101, 103, 104, 105, 106, 107, 108, 109, 110);
+
+UPDATE dbo.products SET add_column3 = 1.1, add_column4 = '2023-02-02T09:09:09'
+WHERE id IN (101, 103, 104, 105, 106, 107, 108, 109, 110, 120, 121);
+
+INSERT INTO dbo.products VALUES (128, 'scooter', 'Small 2-wheel scooter', 
3.14, 'xx', 1, 1.1, '2023-02-02T09:09:09');
+INSERT INTO dbo.products VALUES (129, 'car battery', '12V car battery', 8.1, 
'xx', 2, 1.2, '2023-02-02T09:09:09');
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_drop_columns.sql
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_drop_columns.sql
new file mode 100644
index 0000000000..644c004c97
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_drop_columns.sql
@@ -0,0 +1,47 @@
+--
+-- 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.
+
+USE schema_change_test;
+
+EXEC sys.sp_cdc_disable_table
+    @source_schema = 'dbo',
+    @source_name = 'products',
+    @capture_instance = 'dbo_products_v2';
+
+ALTER TABLE dbo.products DROP COLUMN add_column4;
+
+INSERT INTO dbo.products VALUES (130, 'scooter', 'Small 2-wheel scooter', 
3.14, 'xx', 1, 1.1);
+INSERT INTO dbo.products VALUES (131, 'car battery', '12V car battery', 8.1, 
'xx', 2, 1.2);
+
+ALTER TABLE dbo.products DROP COLUMN add_column1, add_column3;
+
+EXEC sys.sp_cdc_enable_table
+    @source_schema = 'dbo',
+    @source_name = 'products',
+    @role_name = NULL,
+    @capture_instance = 'dbo_products_v3',
+    @supports_net_changes = 0;
+
+-- Rows inserted between capture-instance switches may be emitted by an older
+-- instance without add_column2. Touch them again after v3 is enabled so CDC
+-- emits the current schema and sink rows converge.
+UPDATE dbo.products
+SET add_column2 = CASE id WHEN 130 THEN 11 WHEN 131 THEN 12 END
+WHERE id IN (130, 131);
+
+INSERT INTO dbo.products VALUES (140, 'scooter', 'Small 2-wheel scooter', 
3.14, 1);
+INSERT INTO dbo.products VALUES (141, 'car battery', '12V car battery', 8.1, 
2);
+UPDATE dbo.products SET name = 'updated-after-drop' WHERE id = 140;
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_modify_columns.sql
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_modify_columns.sql
new file mode 100644
index 0000000000..336d8526e8
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_modify_columns.sql
@@ -0,0 +1,36 @@
+--
+-- 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.
+
+USE schema_change_test;
+
+EXEC sys.sp_cdc_disable_table
+    @source_schema = 'dbo',
+    @source_name = 'products',
+    @capture_instance = 'dbo_products_v4';
+
+-- Use bounded length for stable source/sink metadata comparison in IT 
assertions.
+ALTER TABLE dbo.products ALTER COLUMN name NVARCHAR(255) NULL;
+
+EXEC sys.sp_cdc_enable_table
+    @source_schema = 'dbo',
+    @source_name = 'products',
+    @role_name = NULL,
+    @capture_instance = 'dbo_products_v5',
+    @supports_net_changes = 0;
+
+UPDATE dbo.products SET name = N'updated-after-modify' WHERE id = 150;
+INSERT INTO dbo.products VALUES (160, N'scooter-modified', 'Small 2-wheel 
scooter', 3.14, 1);
+INSERT INTO dbo.products VALUES (161, N'car battery-modified', '12V car 
battery', 8.1, 2);
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_rename_columns.sql
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_rename_columns.sql
new file mode 100644
index 0000000000..dee84b1abe
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/ddl/sqlserver_schema_change_rename_columns.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.
+
+USE schema_change_test;
+
+EXEC sys.sp_cdc_disable_table
+    @source_schema = 'dbo',
+    @source_name = 'products',
+    @capture_instance = 'dbo_products_v3';
+
+EXEC sp_rename 'dbo.products.add_column2', 'add_column', 'COLUMN';
+
+EXEC sys.sp_cdc_enable_table
+    @source_schema = 'dbo',
+    @source_name = 'products',
+    @role_name = NULL,
+    @capture_instance = 'dbo_products_v4',
+    @supports_net_changes = 0;
+
+-- SQL Server exposes sp_rename as a capture-instance switch. SeaTunnel now
+-- treats it conservatively as ADD + DROP unless the downstream sink can
+-- preserve existing values itself, so force real post-switch row updates under
+-- the renamed column. A no-op assignment is not enough here because SQL Server
+-- may skip writing CDC rows when the value does not change.
+UPDATE dbo.products
+SET add_column = add_column + 1000
+WHERE id IN (101, 103, 104, 105, 106, 107, 108, 109, 110, 120, 121, 128, 129, 
130, 131, 140, 141);
+
+UPDATE dbo.products
+SET add_column = add_column - 1000
+WHERE id IN (101, 103, 104, 105, 106, 107, 108, 109, 110, 120, 121, 128, 129, 
130, 131, 140, 141);
+
+DELETE FROM dbo.products WHERE id = 130;
+INSERT INTO dbo.products VALUES (150, 'scooter', 'Small 2-wheel scooter', 
3.14, 1);
+INSERT INTO dbo.products VALUES (151, 'car battery', '12V car battery', 8.1, 
2);
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/sqlservercdc_to_sqlserver_with_schema_change.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/sqlservercdc_to_sqlserver_with_schema_change.conf
new file mode 100644
index 0000000000..3ea0f5983c
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-cdc-sqlserver-e2e/src/test/resources/sqlservercdc_to_sqlserver_with_schema_change.conf
@@ -0,0 +1,52 @@
+#
+# 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.
+#
+######
+###### This config file validates SqlServer CDC schema evolution on Flink
+######
+
+env {
+  parallelism = 1
+  job.mode = "STREAMING"
+  checkpoint.interval = 5000
+}
+
+source {
+  SqlServer-CDC {
+    plugin_output = "products"
+    username = "sa"
+    password = "Password!"
+    database-names = ["schema_change_test"]
+    table-names = ["schema_change_test.dbo.products"]
+    url = 
"jdbc:sqlserver://sqlserver-host:1433;databaseName=schema_change_test"
+    schema-changes.enabled = true
+  }
+}
+
+sink {
+  Jdbc {
+    plugin_input = "products"
+    driver = "com.microsoft.sqlserver.jdbc.SQLServerDriver"
+    url = "jdbc:sqlserver://sqlserver-host:1433;encrypt=false"
+    user = "sa"
+    password = "Password!"
+    generate_sink_sql = true
+    database = "schema_change_test"
+    table = "dbo.products_sink"
+    batch_size = 1
+    primary_keys = ["id"]
+  }
+}

Reply via email to