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

dominikriemer pushed a commit to branch add-cdc-connectors
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/add-cdc-connectors by this 
push:
     new 7547716878 Improve schema guessing, add docs
7547716878 is described below

commit 7547716878202fba2427a19bcc0e54500879238e
Author: Dominik Riemer <[email protected]>
AuthorDate: Thu May 7 21:13:01 2026 +0200

    Improve schema guessing, add docs
---
 .../streampipes-connectors-cdc/pom.xml             |   5 +
 .../cdc/adapter/mssql/MsSqlCdcAdapter.java         | 118 +++++++++++++-
 .../cdc/adapter/mssql/MsSqlMetadataClient.java     | 144 ++++++++++++++++-
 .../documentation.md                               | 142 +++++++++++++++++
 .../icon.png                                       | Bin 0 -> 58929 bytes
 .../strings.en                                     |   2 +-
 .../cdc/adapter/mssql/MsSqlCdcAdapterTest.java     | 173 +++++++++++++++++++++
 7 files changed, 573 insertions(+), 11 deletions(-)

diff --git a/streampipes-extensions/streampipes-connectors-cdc/pom.xml 
b/streampipes-extensions/streampipes-connectors-cdc/pom.xml
index 487a0ac203..5bc89ec9c8 100644
--- a/streampipes-extensions/streampipes-connectors-cdc/pom.xml
+++ b/streampipes-extensions/streampipes-connectors-cdc/pom.xml
@@ -75,6 +75,11 @@
             <groupId>com.fasterxml.jackson.core</groupId>
             <artifactId>jackson-databind</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
 </project>
diff --git 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapter.java
 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapter.java
index c0051721c0..c771b701f1 100644
--- 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapter.java
+++ 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapter.java
@@ -18,6 +18,7 @@
 
 package org.apache.streampipes.extensions.connectors.cdc.adapter.mssql;
 
+import org.apache.streampipes.commons.environment.Environments;
 import org.apache.streampipes.commons.exceptions.SpConfigurationException;
 import org.apache.streampipes.commons.exceptions.connect.AdapterException;
 import org.apache.streampipes.extensions.api.connect.IAdapterConfiguration;
@@ -27,8 +28,10 @@ import 
org.apache.streampipes.extensions.api.connect.context.IAdapterGuessSchema
 import 
org.apache.streampipes.extensions.api.connect.context.IAdapterRuntimeContext;
 import 
org.apache.streampipes.extensions.api.extractor.IAdapterParameterExtractor;
 import 
org.apache.streampipes.extensions.api.extractor.IStaticPropertyExtractor;
+import org.apache.streampipes.extensions.api.monitoring.IExtensionsLogger;
 import org.apache.streampipes.extensions.api.runtime.SupportsRuntimeConfig;
 import org.apache.streampipes.model.connect.guess.SampleData;
+import org.apache.streampipes.model.extensions.ExtensionAssetType;
 import 
org.apache.streampipes.model.staticproperty.RuntimeResolvableOneOfStaticProperty;
 import org.apache.streampipes.model.staticproperty.StaticProperty;
 import org.apache.streampipes.sdk.builder.adapter.AdapterConfigurationBuilder;
@@ -56,6 +59,7 @@ import java.time.format.DateTimeParseException;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
@@ -75,6 +79,7 @@ public class MsSqlCdcAdapter implements StreamPipesAdapter, 
SupportsRuntimeConfi
   private static final Logger LOG = 
LoggerFactory.getLogger(MsSqlCdcAdapter.class);
 
   public static final String ID = 
"org.apache.streampipes.connect.cdc.adapter.mssql";
+  static final long ENGINE_STARTUP_TIMEOUT_SECONDS = 10;
 
   private static final TypeReference<Map<String, Object>> MAP_TYPE = new 
TypeReference<>() {
   };
@@ -86,6 +91,9 @@ public class MsSqlCdcAdapter implements StreamPipesAdapter, 
SupportsRuntimeConfi
   private ExecutorService executorService;
   private Map<String, MsSqlMetadataClient.TemporalColumnMode> temporalColumns;
   private ZoneId configuredZoneId;
+  private volatile Throwable engineFailure;
+  private volatile boolean connectorStarted;
+  private volatile boolean stopRequested;
 
   public MsSqlCdcAdapter() {
     this(new ObjectMapper(), new MsSqlMetadataClient());
@@ -114,6 +122,7 @@ public class MsSqlCdcAdapter implements StreamPipesAdapter, 
SupportsRuntimeConfi
   public IAdapterConfiguration declareConfig() {
     return AdapterConfigurationBuilder.create(ID, 0, MsSqlCdcAdapter::new)
         .withLocales(Locales.EN)
+        .withAssets(ExtensionAssetType.DOCUMENTATION, ExtensionAssetType.ICON)
         .requiredTextParameter(Labels.withId(HOST_KEY))
         .requiredIntegerParameter(Labels.withId(PORT_KEY), 1433)
         .requiredTextParameter(Labels.withId(DATABASE_KEY))
@@ -121,7 +130,7 @@ public class MsSqlCdcAdapter implements StreamPipesAdapter, 
SupportsRuntimeConfi
         .requiredSecret(Labels.withId(PASSWORD_KEY))
         .requiredSlideToggle(Labels.withId(ENCRYPT_KEY), true)
         .requiredSlideToggle(Labels.withId(TRUST_SERVER_CERTIFICATE_KEY), 
false)
-        .requiredTextParameter(Labels.withId(TIMEZONE_KEY), 
ZoneId.systemDefault().getId())
+        .requiredTextParameter(Labels.withId(TIMEZONE_KEY), 
ZoneOffset.UTC.getId())
         .requiredSingleValueSelectionFromContainer(
             Labels.withId(TABLE_KEY),
             java.util.List.of(HOST_KEY, PORT_KEY, DATABASE_KEY, USERNAME_KEY, 
PASSWORD_KEY, ENCRYPT_KEY,
@@ -139,20 +148,35 @@ public class MsSqlCdcAdapter implements 
StreamPipesAdapter, SupportsRuntimeConfi
     metadataClient.validateSelectedTable(config);
     this.temporalColumns = metadataClient.describeTemporalColumns(config);
     this.configuredZoneId = config.getZoneId();
+    this.engineFailure = null;
+    this.connectorStarted = false;
+    this.stopRequested = false;
 
-    Properties properties = buildDebeziumProperties(config);
+    CountDownLatch startupLatch = new CountDownLatch(1);
+    Properties properties = buildDebeziumProperties(config, 
extractor.getAdapterDescription().getElementId());
     this.engine = DebeziumEngine.create(Json.class)
         .using(properties)
+        .using((success, message, error) -> handleEngineCompletion(
+            success,
+            message,
+            error,
+            adapterRuntimeContext.getLogger(),
+            startupLatch
+        ))
+        .using(createConnectorCallback(startupLatch))
         .notifying(record -> processRecord(record, collector))
         .build();
 
     this.executorService = Executors.newSingleThreadExecutor();
     this.executorService.execute(engine);
+    awaitStartup(startupLatch, adapterRuntimeContext.getLogger());
   }
 
   @Override
   public void onAdapterStopped(IAdapterParameterExtractor extractor,
                                IAdapterRuntimeContext adapterRuntimeContext) 
throws AdapterException {
+    this.stopRequested = true;
+
     if (engine != null) {
       try {
         engine.close();
@@ -177,6 +201,9 @@ public class MsSqlCdcAdapter implements StreamPipesAdapter, 
SupportsRuntimeConfi
     this.executorService = null;
     this.temporalColumns = null;
     this.configuredZoneId = null;
+    this.engineFailure = null;
+    this.connectorStarted = false;
+    this.stopRequested = false;
   }
 
   @Override
@@ -189,9 +216,9 @@ public class MsSqlCdcAdapter implements StreamPipesAdapter, 
SupportsRuntimeConfi
         .build();
   }
 
-  private Properties buildDebeziumProperties(MsSqlCdcAdapterConfig config) {
+  Properties buildDebeziumProperties(MsSqlCdcAdapterConfig config, String 
elementId) {
     Properties props = new Properties();
-    String logicalName = createLogicalName(config);
+    String logicalName = createLogicalName(config, elementId);
     props.setProperty("name", logicalName + "-engine");
     props.setProperty("connector.class", 
"io.debezium.connector.sqlserver.SqlServerConnector");
     props.setProperty("offset.storage", 
"org.apache.kafka.connect.storage.MemoryOffsetBackingStore");
@@ -204,6 +231,10 @@ public class MsSqlCdcAdapter implements 
StreamPipesAdapter, SupportsRuntimeConfi
     props.setProperty("snapshot.mode", "no_data");
     props.setProperty("tasks.max", "1");
     props.setProperty("decimal.handling.mode", "double");
+    props.setProperty(
+        "poll.interval.ms",
+        
String.valueOf(Environments.getEnvironment().getMsSqlCdcPollIntervalMs().getValueOrDefault())
+    );
 
     props.setProperty("database.hostname", config.getHost());
     props.setProperty("database.port", String.valueOf(config.getPort()));
@@ -218,8 +249,10 @@ public class MsSqlCdcAdapter implements 
StreamPipesAdapter, SupportsRuntimeConfi
     return props;
   }
 
-  private String createLogicalName(MsSqlCdcAdapterConfig config) {
-    return (config.getDatabase() + "_" + config.getTable())
+  String createLogicalName(MsSqlCdcAdapterConfig config, String elementId) {
+    String effectiveElementId = elementId == null || elementId.isBlank() ? 
"adapter" : elementId;
+
+    return (config.getDatabase() + "_" + config.getTable() + "_" + 
effectiveElementId)
         .replace('.', '_')
         .replaceAll("[^A-Za-z0-9_]", "_")
         .toLowerCase();
@@ -261,7 +294,7 @@ public class MsSqlCdcAdapter implements StreamPipesAdapter, 
SupportsRuntimeConfi
     return normalized;
   }
 
-  private Object normalizeTemporalValue(Object value, 
MsSqlMetadataClient.TemporalColumnMode temporalMode) {
+  Object normalizeTemporalValue(Object value, 
MsSqlMetadataClient.TemporalColumnMode temporalMode) {
     if (value == null) {
       return null;
     }
@@ -309,4 +342,75 @@ public class MsSqlCdcAdapter implements 
StreamPipesAdapter, SupportsRuntimeConfi
   private ZoneId getConfiguredZoneId() {
     return configuredZoneId == null ? ZoneOffset.UTC : configuredZoneId;
   }
+
+  private DebeziumEngine.ConnectorCallback 
createConnectorCallback(CountDownLatch startupLatch) {
+    return new DebeziumEngine.ConnectorCallback() {
+      @Override
+      public void connectorStarted() {
+        connectorStarted = true;
+        startupLatch.countDown();
+      }
+    };
+  }
+
+  void handleEngineCompletion(boolean success,
+                              String message,
+                              Throwable error,
+                              IExtensionsLogger logger,
+                              CountDownLatch startupLatch) {
+    if (error != null) {
+      this.engineFailure = error;
+    } else if (!success) {
+      this.engineFailure = new AdapterException(
+          "Debezium engine stopped unexpectedly" + (message == null || 
message.isBlank() ? "." : ": " + message)
+      );
+    }
+
+    if (startupLatch != null) {
+      startupLatch.countDown();
+    }
+
+    if (stopRequested) {
+      return;
+    }
+
+    if (engineFailure != null) {
+      logger.error("Debezium engine failed for MSSQL CDC adapter.", 
asException(engineFailure));
+    } else if (success) {
+      logger.warn("Debezium Engine Stopped", "The embedded Debezium engine 
stopped unexpectedly.");
+    } else {
+      logger.warn(
+          "Debezium Engine Stopped",
+          message == null || message.isBlank() ? "The embedded Debezium engine 
stopped unexpectedly." : message
+      );
+    }
+  }
+
+  private void awaitStartup(CountDownLatch startupLatch, IExtensionsLogger 
logger) throws AdapterException {
+    try {
+      boolean completed = startupLatch.await(ENGINE_STARTUP_TIMEOUT_SECONDS, 
TimeUnit.SECONDS);
+      if (engineFailure != null) {
+        throw new AdapterException("Failed to start Debezium engine: " + 
engineFailure.getMessage(), engineFailure);
+      }
+
+      if (!completed || !connectorStarted) {
+        logger.warn(
+            "Debezium Startup Delayed",
+            "The embedded Debezium engine did not confirm startup within "
+                + ENGINE_STARTUP_TIMEOUT_SECONDS + " seconds."
+        );
+      }
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new AdapterException("Interrupted while waiting for Debezium 
engine startup.", e);
+    }
+  }
+
+  private Exception asException(Throwable throwable) {
+    if (throwable instanceof Exception exception) {
+      return exception;
+    }
+
+    return new AdapterException(throwable.getMessage(), throwable);
+  }
 }
diff --git 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlMetadataClient.java
 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlMetadataClient.java
index 4d4d07d582..c2d581a5f0 100644
--- 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlMetadataClient.java
+++ 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlMetadataClient.java
@@ -22,17 +22,27 @@ import 
org.apache.streampipes.commons.exceptions.SpConfigurationException;
 import org.apache.streampipes.commons.exceptions.connect.AdapterException;
 import org.apache.streampipes.model.staticproperty.Option;
 
+import java.math.BigDecimal;
 import java.sql.Connection;
 import java.sql.DatabaseMetaData;
+import java.sql.Date;
 import java.sql.DriverManager;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
 import java.sql.SQLException;
+import java.sql.Time;
+import java.sql.Timestamp;
 import java.sql.Types;
+import java.time.Instant;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.time.OffsetDateTime;
 import java.time.ZoneId;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
+import java.util.Base64;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
@@ -50,6 +60,8 @@ public class MsSqlMetadataClient {
           + "WHERE t.is_ms_shipped = 0 AND t.is_tracked_by_cdc = 1 "
           + "ORDER BY s.name, t.name";
 
+  private static final String SAMPLE_ROW_QUERY_TEMPLATE = "SELECT TOP 1 * FROM 
[%s].[%s]";
+
   public List<Option> discoverTables(MsSqlCdcAdapterConfig config) throws 
SpConfigurationException {
     try (Connection connection = openConnection(config)) {
       validateCdcEnabled(connection);
@@ -95,9 +107,15 @@ public class MsSqlMetadataClient {
       validateCdcEnabled(connection);
 
       Map<String, ColumnDescriptor> columns = describeColumns(connection, 
tableRef);
-      Map<String, Object> sample = new LinkedHashMap<>();
       ZoneId zoneId = config.getZoneId();
-      columns.forEach((columnName, descriptor) -> sample.put(columnName, 
placeholderForType(descriptor, zoneId)));
+      Map<String, Object> sample = fetchRealSampleRow(connection, tableRef, 
columns, zoneId);
+
+      if (sample == null) {
+        sample = new LinkedHashMap<>();
+        for (Map.Entry<String, ColumnDescriptor> entry : columns.entrySet()) {
+          sample.put(entry.getKey(), placeholderForType(entry.getValue(), 
zoneId));
+        }
+      }
 
       if (sample.isEmpty()) {
         throw new AdapterException("No columns found for table " + 
config.getTable());
@@ -176,10 +194,41 @@ public class MsSqlMetadataClient {
     return columns;
   }
 
+  private Map<String, Object> fetchRealSampleRow(Connection connection,
+                                                 TableRef tableRef,
+                                                 Map<String, ColumnDescriptor> 
columns,
+                                                 ZoneId zoneId) throws 
SQLException {
+    String query = SAMPLE_ROW_QUERY_TEMPLATE.formatted(
+        escapeIdentifier(tableRef.schema()),
+        escapeIdentifier(tableRef.table())
+    );
+
+    try (PreparedStatement statement = connection.prepareStatement(query);
+         ResultSet resultSet = statement.executeQuery()) {
+      if (!resultSet.next()) {
+        return null;
+      }
+
+      ResultSetMetaData metaData = resultSet.getMetaData();
+      Map<String, Object> sample = new LinkedHashMap<>();
+      for (int columnIndex = 1; columnIndex <= metaData.getColumnCount(); 
columnIndex++) {
+        String columnName = metaData.getColumnLabel(columnIndex);
+        ColumnDescriptor descriptor = columns.getOrDefault(
+            columnName,
+            new ColumnDescriptor(columnName, 
metaData.getColumnType(columnIndex), metaData.getColumnTypeName(columnIndex), 0)
+        );
+        Object rawValue = resultSet.getObject(columnIndex);
+        sample.put(columnName, sampleValue(rawValue, descriptor, zoneId));
+      }
+
+      return sample;
+    }
+  }
+
   private Object placeholderForType(ColumnDescriptor descriptor, ZoneId 
zoneId) {
     TemporalColumnMode temporalMode = getTemporalColumnMode(descriptor);
     if (temporalMode != null) {
-      return sampleTemporalValue(temporalMode, zoneId);
+      return (double) sampleTemporalValue(temporalMode, zoneId);
     }
 
     return switch (descriptor.jdbcType()) {
@@ -191,6 +240,27 @@ public class MsSqlMetadataClient {
     };
   }
 
+  private Object sampleValue(Object rawValue, ColumnDescriptor descriptor, 
ZoneId zoneId) {
+    if (rawValue == null) {
+      return placeholderForType(descriptor, zoneId);
+    }
+
+    TemporalColumnMode temporalMode = getTemporalColumnMode(descriptor);
+    if (temporalMode != null) {
+      return (double) temporalValue(rawValue, temporalMode, zoneId);
+    }
+
+    if (rawValue instanceof BigDecimal decimalValue) {
+      return decimalValue.doubleValue();
+    }
+
+    if (rawValue instanceof byte[] bytes) {
+      return Base64.getEncoder().encodeToString(bytes);
+    }
+
+    return rawValue;
+  }
+
   private TemporalColumnMode getTemporalColumnMode(ColumnDescriptor 
descriptor) {
     String normalizedTypeName = descriptor.typeName() == null
         ? ""
@@ -247,6 +317,74 @@ public class MsSqlMetadataClient {
     };
   }
 
+  private long temporalValue(Object rawValue, TemporalColumnMode temporalMode, 
ZoneId zoneId) {
+    return switch (temporalMode) {
+      case DATE_EPOCH_MILLIS -> toDateEpochMillis(rawValue, zoneId);
+      case TIME_MILLIS, TIME_MICROS, TIME_NANOS -> toTimeMillis(rawValue);
+      case TIMESTAMP_MILLIS, TIMESTAMP_MICROS, TIMESTAMP_NANOS -> 
toTimestampEpochMillis(rawValue, zoneId);
+      case DATETIMEOFFSET_MILLIS -> toDateTimeOffsetEpochMillis(rawValue, 
zoneId);
+    };
+  }
+
+  private long toDateEpochMillis(Object rawValue, ZoneId zoneId) {
+    if (rawValue instanceof Date dateValue) {
+      return 
dateValue.toLocalDate().atStartOfDay(zoneId).toInstant().toEpochMilli();
+    } else if (rawValue instanceof LocalDate localDate) {
+      return localDate.atStartOfDay(zoneId).toInstant().toEpochMilli();
+    }
+
+    return 
LocalDate.parse(rawValue.toString()).atStartOfDay(zoneId).toInstant().toEpochMilli();
+  }
+
+  private long toTimeMillis(Object rawValue) {
+    LocalTime localTime;
+    if (rawValue instanceof Time timeValue) {
+      localTime = timeValue.toLocalTime();
+    } else if (rawValue instanceof LocalTime parsedLocalTime) {
+      localTime = parsedLocalTime;
+    } else {
+      localTime = LocalTime.parse(rawValue.toString());
+    }
+
+    return localTime.toNanoOfDay() / 1_000_000;
+  }
+
+  private long toTimestampEpochMillis(Object rawValue, ZoneId zoneId) {
+    if (rawValue instanceof Timestamp timestampValue) {
+      return 
timestampValue.toLocalDateTime().atZone(zoneId).toInstant().toEpochMilli();
+    } else if (rawValue instanceof LocalDateTime localDateTime) {
+      return localDateTime.atZone(zoneId).toInstant().toEpochMilli();
+    } else if (rawValue instanceof Instant instant) {
+      return instant.toEpochMilli();
+    }
+
+    String stringValue = rawValue.toString();
+    try {
+      return Instant.parse(stringValue).toEpochMilli();
+    } catch (DateTimeParseException ignored) {
+      return LocalDateTime.parse(stringValue.replace(' ', 
'T')).atZone(zoneId).toInstant().toEpochMilli();
+    }
+  }
+
+  private long toDateTimeOffsetEpochMillis(Object rawValue, ZoneId zoneId) {
+    if (rawValue instanceof OffsetDateTime offsetDateTime) {
+      return offsetDateTime.toInstant().toEpochMilli();
+    } else if (rawValue instanceof Timestamp timestampValue) {
+      return timestampValue.toInstant().toEpochMilli();
+    }
+
+    String stringValue = rawValue.toString().replace(' ', 'T');
+    try {
+      return OffsetDateTime.parse(stringValue).toInstant().toEpochMilli();
+    } catch (DateTimeParseException ignored) {
+      return 
LocalDateTime.parse(stringValue).atZone(zoneId).toInstant().toEpochMilli();
+    }
+  }
+
+  private String escapeIdentifier(String identifier) {
+    return identifier.replace("]", "]]");
+  }
+
   private record TableRef(String schema, String table) {
   }
 
diff --git 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/documentation.md
 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/documentation.md
new file mode 100644
index 0000000000..1566a3bea1
--- /dev/null
+++ 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/documentation.md
@@ -0,0 +1,142 @@
+<!--
+  ~ 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.
+  ~
+  -->
+
+## Microsoft SQL Server CDC Adapter
+
+***
+
+## Description
+
+This adapter uses the embedded Debezium Engine to capture change events from a 
CDC-enabled Microsoft SQL Server table.
+
+Current behavior:
+
+* only newly inserted rows are emitted
+* updates and deletes are ignored
+* the output event is flat and contains only the row fields
+* Debezium metadata is not included in emitted events
+* adapter state is kept in memory only
+
+This means the adapter starts at the current log position and does not read 
old rows when it starts.
+If the adapter is stopped, its position is lost and rows written while it is 
down can be missed.
+
+***
+
+## Required Input
+
+None.
+
+***
+
+## SQL Server Prerequisites
+
+The adapter expects the following SQL Server setup:
+
+* CDC is enabled on the selected database
+* CDC is enabled on the selected table
+* SQL Server Agent is running
+* the configured SQL Server user can access the database metadata and captured 
CDC data
+
+The configuration dialog only lists CDC-enabled tables from the selected 
database.
+
+***
+
+## Configuration
+
+### Host
+
+The SQL Server host name or IP address.
+
+### Port
+
+The SQL Server TCP port, for example `1433`.
+
+### Database
+
+The name of the CDC-enabled SQL Server database.
+
+### Username / Password
+
+Credentials used to connect to SQL Server.
+
+### Encrypt Connection
+
+Enable TLS for the SQL Server connection.
+
+### Trust Server Certificate
+
+Trust the SQL Server certificate without validating its chain.
+This is useful for local or test environments with self-signed certificates.
+
+### Timezone
+
+Timezone used for SQL Server temporal types without embedded timezone 
information, such as `DATETIME` and `DATETIME2`.
+
+Use an IANA timezone ID such as:
+
+* `UTC`
+* `Europe/Berlin`
+* `America/New_York`
+
+The default is `UTC`.
+
+### Table
+
+Select one CDC-enabled table from the configured database.
+
+***
+
+## Output Behavior
+
+Each insert event produces one StreamPipes event.
+
+Example output:
+
+```json
+{
+  "id": 42,
+  "temperature": 23.5,
+  "pressure": 1007.2,
+  "createdAt": 1778154600000
+}
+```
+
+Type handling:
+
+* `DECIMAL` and `NUMERIC` values are emitted as JSON numbers
+* `DATE`, `DATETIME`, `SMALLDATETIME`, `DATETIME2`, and `DATETIMEOFFSET` 
values are normalized to epoch milliseconds
+* `TIME` values are normalized to milliseconds past midnight
+* binary values are emitted as Base64 strings
+
+Runtime polling:
+
+* Debezium polling is controlled through the environment variable 
`SP_CDC_MSSQL_POLL_INTERVAL_MS`
+* the default polling interval is `1000` milliseconds
+
+***
+
+## Limitations
+
+Current limitations:
+
+* single-table capture only
+* insert events only
+* no snapshot of existing rows
+* no offset persistence across adapter restarts
+* no schema-history persistence across adapter restarts
+* timestamp semantics for downstream sinks still depend on the field selection 
in StreamPipes
diff --git 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/icon.png
 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/icon.png
new file mode 100644
index 0000000000..0a6ff414b7
Binary files /dev/null and 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/icon.png
 differ
diff --git 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/strings.en
 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/strings.en
index c73518e0f7..a54beb12af 100644
--- 
a/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/strings.en
+++ 
b/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/strings.en
@@ -41,7 +41,7 @@ trust-server-certificate.title=Trust Server Certificate
 trust-server-certificate.description=Trust the SQL Server certificate without 
validating its chain
 
 timezone.title=Timezone
-timezone.description=Timezone to assume for SQL Server DATETIME and DATETIME2 
values without timezone information, for example Europe/Berlin
+timezone.description=Timezone to assume for SQL Server DATETIME and DATETIME2 
values without timezone information, for example Europe/Berlin. Defaults to UTC.
 
 table.title=Table
 table.description=Select a CDC-enabled table from the configured database
diff --git 
a/streampipes-extensions/streampipes-connectors-cdc/src/test/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapterTest.java
 
b/streampipes-extensions/streampipes-connectors-cdc/src/test/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapterTest.java
new file mode 100644
index 0000000000..90b2a015eb
--- /dev/null
+++ 
b/streampipes-extensions/streampipes-connectors-cdc/src/test/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapterTest.java
@@ -0,0 +1,173 @@
+/*
+ * 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.streampipes.extensions.connectors.cdc.adapter.mssql;
+
+import org.apache.streampipes.extensions.api.monitoring.IExtensionsLogger;
+import org.apache.streampipes.model.monitoring.SpLogMessage;
+import org.apache.streampipes.model.staticproperty.FreeTextStaticProperty;
+import org.apache.streampipes.model.staticproperty.Option;
+import 
org.apache.streampipes.model.staticproperty.RuntimeResolvableOneOfStaticProperty;
+import org.apache.streampipes.model.staticproperty.SecretStaticProperty;
+import org.apache.streampipes.model.staticproperty.SlideToggleStaticProperty;
+import org.apache.streampipes.sdk.extractor.StaticPropertyExtractor;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class MsSqlCdcAdapterTest {
+
+  @Test
+  void configExtractionAllowsMissingTableDuringRuntimeResolution() {
+    RuntimeResolvableOneOfStaticProperty tableProperty =
+        new 
RuntimeResolvableOneOfStaticProperty(MsSqlCdcAdapterConfig.TABLE_KEY, "", "");
+    tableProperty.setOptions(List.of(new Option("dbo.Measurements")));
+
+    MsSqlCdcAdapterConfig config = MsSqlCdcAdapterConfig.from(
+        StaticPropertyExtractor.from(List.of(
+            textProperty(MsSqlCdcAdapterConfig.HOST_KEY, "localhost"),
+            textProperty(MsSqlCdcAdapterConfig.PORT_KEY, "1433"),
+            textProperty(MsSqlCdcAdapterConfig.DATABASE_KEY, "testdb"),
+            textProperty(MsSqlCdcAdapterConfig.USERNAME_KEY, "sa"),
+            secretProperty(MsSqlCdcAdapterConfig.PASSWORD_KEY, "secret"),
+            toggleProperty(MsSqlCdcAdapterConfig.ENCRYPT_KEY, true),
+            toggleProperty(MsSqlCdcAdapterConfig.TRUST_SERVER_CERTIFICATE_KEY, 
false),
+            textProperty(MsSqlCdcAdapterConfig.TIMEZONE_KEY, "UTC"),
+            tableProperty
+        )),
+        false
+    );
+
+    assertNull(config.getTable());
+  }
+
+  @Test
+  void buildDebeziumPropertiesUsesDecimalHandlingAndElementId() {
+    MsSqlCdcAdapter adapter = new MsSqlCdcAdapter();
+    MsSqlCdcAdapterConfig config = new MsSqlCdcAdapterConfig(
+        "localhost",
+        1433,
+        "testdb",
+        "sa",
+        "secret",
+        "dbo.Measurements",
+        true,
+        false,
+        "UTC"
+    );
+
+    Properties properties = adapter.buildDebeziumProperties(config, 
"adapter-42");
+
+    assertEquals("double", properties.getProperty("decimal.handling.mode"));
+    assertEquals("1000", properties.getProperty("poll.interval.ms"));
+    assertEquals("testdb_dbo_measurements_adapter_42", 
properties.getProperty("topic.prefix"));
+    assertEquals("testdb_dbo_measurements_adapter_42-engine", 
properties.getProperty("name"));
+  }
+
+  @Test
+  void timestampNormalizationUsesConfiguredTimezone() throws Exception {
+    MsSqlCdcAdapter adapter = new MsSqlCdcAdapter();
+    Field configuredZoneId = 
MsSqlCdcAdapter.class.getDeclaredField("configuredZoneId");
+    configuredZoneId.setAccessible(true);
+    configuredZoneId.set(adapter, ZoneId.of("Europe/Berlin"));
+
+    Object normalized = adapter.normalizeTemporalValue(
+        1_778_161_800_000L,
+        MsSqlMetadataClient.TemporalColumnMode.TIMESTAMP_MILLIS
+    );
+
+    long expected = LocalDateTime.of(2026, 5, 7, 13, 50)
+        .atZone(ZoneId.of("Europe/Berlin"))
+        .toInstant()
+        .toEpochMilli();
+
+    assertEquals(expected, normalized);
+  }
+
+  @Test
+  void engineCompletionReportsFailuresToRuntimeLogger() throws Exception {
+    MsSqlCdcAdapter adapter = new MsSqlCdcAdapter();
+    TestLogger logger = new TestLogger();
+    RuntimeException cause = new RuntimeException("boom");
+
+    adapter.handleEngineCompletion(false, "connector failed", cause, logger, 
new CountDownLatch(1));
+
+    Field engineFailure = 
MsSqlCdcAdapter.class.getDeclaredField("engineFailure");
+    engineFailure.setAccessible(true);
+
+    assertNotNull(engineFailure.get(adapter));
+    assertEquals("Debezium engine failed for MSSQL CDC adapter.", 
logger.errorDetails);
+    assertEquals(cause, logger.errorException);
+  }
+
+  private static FreeTextStaticProperty textProperty(String internalName, 
String value) {
+    FreeTextStaticProperty property = FreeTextStaticProperty.of(internalName, 
value);
+    property.setInternalName(internalName);
+    return property;
+  }
+
+  private static SecretStaticProperty secretProperty(String internalName, 
String value) {
+    SecretStaticProperty property = new SecretStaticProperty(internalName, "", 
"");
+    property.setValue(value);
+    return property;
+  }
+
+  private static SlideToggleStaticProperty toggleProperty(String internalName, 
boolean selected) {
+    SlideToggleStaticProperty property = new 
SlideToggleStaticProperty(internalName, "", "", selected);
+    property.setSelected(selected);
+    return property;
+  }
+
+  private static class TestLogger implements IExtensionsLogger {
+    private String errorDetails;
+    private Exception errorException;
+
+    @Override
+    public void log(SpLogMessage logMessage) {
+    }
+
+    @Override
+    public void error(Exception e) {
+      this.errorException = e;
+    }
+
+    @Override
+    public void error(String details, Exception e) {
+      this.errorDetails = details;
+      this.errorException = e;
+    }
+
+    @Override
+    public void info(String title, String details) {
+    }
+
+    @Override
+    public void warn(String title, String details) {
+    }
+  }
+}

Reply via email to