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
commit cd714253b2e6771e1ed8b057d59739148af91f95 Author: Dominik Riemer <[email protected]> AuthorDate: Thu May 7 14:55:12 2026 +0200 feat: Add MSSQL CDC connector --- streampipes-extensions/pom.xml | 1 + .../streampipes-connectors-cdc/pom.xml | 80 ++++++ .../connectors/cdc/CdcConnectorsModuleExport.java | 46 +++ .../cdc/adapter/mssql/MsSqlCdcAdapter.java | 312 +++++++++++++++++++++ .../cdc/adapter/mssql/MsSqlCdcAdapterConfig.java | 143 ++++++++++ .../cdc/adapter/mssql/MsSqlMetadataClient.java | 266 ++++++++++++++++++ .../strings.en | 47 ++++ .../streampipes-extensions-all-iiot/pom.xml | 6 + .../extensions/all/iiot/AllExtensionsIIoTInit.java | 4 +- .../streampipes-extensions-all-jvm/pom.xml | 5 + .../extensions/all/jvm/AllExtensionsInit.java | 2 + ...tic-runtime-resolvable-oneof-input.component.ts | 1 + 12 files changed, 912 insertions(+), 1 deletion(-) diff --git a/streampipes-extensions/pom.xml b/streampipes-extensions/pom.xml index d540061ea2..2916a15a79 100644 --- a/streampipes-extensions/pom.xml +++ b/streampipes-extensions/pom.xml @@ -35,6 +35,7 @@ <module>streampipes-connect-adapters-iiot</module> <module>streampipes-connectors-camel</module> + <module>streampipes-connectors-cdc</module> <module>streampipes-connectors-influx</module> <module>streampipes-connectors-kafka</module> <module>streampipes-connectors-mqtt</module> diff --git a/streampipes-extensions/streampipes-connectors-cdc/pom.xml b/streampipes-extensions/streampipes-connectors-cdc/pom.xml new file mode 100644 index 0000000000..487a0ac203 --- /dev/null +++ b/streampipes-extensions/streampipes-connectors-cdc/pom.xml @@ -0,0 +1,80 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ~ 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. + ~ + --> + +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.streampipes</groupId> + <artifactId>streampipes-extensions</artifactId> + <version>0.99.0-SNAPSHOT</version> + </parent> + + <artifactId>streampipes-connectors-cdc</artifactId> + + <properties> + <debezium.version>3.4.3.Final</debezium.version> + </properties> + + <dependencies> + <dependency> + <groupId>org.apache.streampipes</groupId> + <artifactId>streampipes-extensions-api</artifactId> + <version>0.99.0-SNAPSHOT</version> + </dependency> + <dependency> + <groupId>org.apache.streampipes</groupId> + <artifactId>streampipes-extensions-management</artifactId> + <version>0.99.0-SNAPSHOT</version> + </dependency> + <dependency> + <groupId>org.apache.streampipes</groupId> + <artifactId>streampipes-sdk</artifactId> + <version>0.99.0-SNAPSHOT</version> + </dependency> + <dependency> + <groupId>io.debezium</groupId> + <artifactId>debezium-api</artifactId> + <version>${debezium.version}</version> + </dependency> + <dependency> + <groupId>io.debezium</groupId> + <artifactId>debezium-embedded</artifactId> + <version>${debezium.version}</version> + <exclusions> + <exclusion> + <groupId>io.github.classgraph</groupId> + <artifactId>classgraph</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>io.debezium</groupId> + <artifactId>debezium-connector-sqlserver</artifactId> + <version>${debezium.version}</version> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> + </dependency> + </dependencies> + +</project> diff --git a/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/CdcConnectorsModuleExport.java b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/CdcConnectorsModuleExport.java new file mode 100644 index 0000000000..604eb97105 --- /dev/null +++ b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/CdcConnectorsModuleExport.java @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.apache.streampipes.extensions.connectors.cdc; + +import org.apache.streampipes.extensions.api.connect.StreamPipesAdapter; +import org.apache.streampipes.extensions.api.declarer.IExtensionModuleExport; +import org.apache.streampipes.extensions.api.migration.IModelMigrator; +import org.apache.streampipes.extensions.api.pe.IStreamPipesPipelineElement; +import org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapter; + +import java.util.Collections; +import java.util.List; + +public class CdcConnectorsModuleExport implements IExtensionModuleExport { + + @Override + public List<StreamPipesAdapter> adapters() { + return List.of(new MsSqlCdcAdapter()); + } + + @Override + public List<IStreamPipesPipelineElement<?>> pipelineElements() { + return Collections.emptyList(); + } + + @Override + public List<IModelMigrator<?, ?>> migrators() { + return Collections.emptyList(); + } +} 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 new file mode 100644 index 0000000000..c0051721c0 --- /dev/null +++ b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapter.java @@ -0,0 +1,312 @@ +/* + * 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.commons.exceptions.SpConfigurationException; +import org.apache.streampipes.commons.exceptions.connect.AdapterException; +import org.apache.streampipes.extensions.api.connect.IAdapterConfiguration; +import org.apache.streampipes.extensions.api.connect.IEventCollector; +import org.apache.streampipes.extensions.api.connect.StreamPipesAdapter; +import org.apache.streampipes.extensions.api.connect.context.IAdapterGuessSchemaContext; +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.runtime.SupportsRuntimeConfig; +import org.apache.streampipes.model.connect.guess.SampleData; +import org.apache.streampipes.model.staticproperty.RuntimeResolvableOneOfStaticProperty; +import org.apache.streampipes.model.staticproperty.StaticProperty; +import org.apache.streampipes.sdk.builder.adapter.AdapterConfigurationBuilder; +import org.apache.streampipes.sdk.builder.adapter.SampleDataBuilder; +import org.apache.streampipes.sdk.helpers.Labels; +import org.apache.streampipes.sdk.helpers.Locales; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.debezium.engine.ChangeEvent; +import io.debezium.engine.DebeziumEngine; +import io.debezium.engine.format.Json; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.DATABASE_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.ENCRYPT_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.HOST_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.PASSWORD_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.PORT_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.TABLE_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.TIMEZONE_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.TRUST_SERVER_CERTIFICATE_KEY; +import static org.apache.streampipes.extensions.connectors.cdc.adapter.mssql.MsSqlCdcAdapterConfig.USERNAME_KEY; + +public class MsSqlCdcAdapter implements StreamPipesAdapter, SupportsRuntimeConfig { + + private static final Logger LOG = LoggerFactory.getLogger(MsSqlCdcAdapter.class); + + public static final String ID = "org.apache.streampipes.connect.cdc.adapter.mssql"; + + private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { + }; + + private final ObjectMapper mapper; + private final MsSqlMetadataClient metadataClient; + + private DebeziumEngine<ChangeEvent<String, String>> engine; + private ExecutorService executorService; + private Map<String, MsSqlMetadataClient.TemporalColumnMode> temporalColumns; + private ZoneId configuredZoneId; + + public MsSqlCdcAdapter() { + this(new ObjectMapper(), new MsSqlMetadataClient()); + } + + MsSqlCdcAdapter(ObjectMapper mapper, MsSqlMetadataClient metadataClient) { + this.mapper = mapper; + this.metadataClient = metadataClient; + } + + @Override + public StaticProperty resolveConfiguration(String staticPropertyInternalName, + IStaticPropertyExtractor extractor) throws SpConfigurationException { + if (!TABLE_KEY.equals(staticPropertyInternalName)) { + return null; + } + + MsSqlCdcAdapterConfig config = MsSqlCdcAdapterConfig.from(extractor, false); + RuntimeResolvableOneOfStaticProperty tableProperty = + extractor.getStaticPropertyByName(TABLE_KEY, RuntimeResolvableOneOfStaticProperty.class); + tableProperty.setOptions(metadataClient.discoverTables(config)); + return tableProperty; + } + + @Override + public IAdapterConfiguration declareConfig() { + return AdapterConfigurationBuilder.create(ID, 0, MsSqlCdcAdapter::new) + .withLocales(Locales.EN) + .requiredTextParameter(Labels.withId(HOST_KEY)) + .requiredIntegerParameter(Labels.withId(PORT_KEY), 1433) + .requiredTextParameter(Labels.withId(DATABASE_KEY)) + .requiredTextParameter(Labels.withId(USERNAME_KEY)) + .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()) + .requiredSingleValueSelectionFromContainer( + Labels.withId(TABLE_KEY), + java.util.List.of(HOST_KEY, PORT_KEY, DATABASE_KEY, USERNAME_KEY, PASSWORD_KEY, ENCRYPT_KEY, + TIMEZONE_KEY, + TRUST_SERVER_CERTIFICATE_KEY) + ) + .buildConfiguration(); + } + + @Override + public void onAdapterStarted(IAdapterParameterExtractor extractor, + IEventCollector collector, + IAdapterRuntimeContext adapterRuntimeContext) throws AdapterException { + MsSqlCdcAdapterConfig config = MsSqlCdcAdapterConfig.from(extractor.getStaticPropertyExtractor()); + metadataClient.validateSelectedTable(config); + this.temporalColumns = metadataClient.describeTemporalColumns(config); + this.configuredZoneId = config.getZoneId(); + + Properties properties = buildDebeziumProperties(config); + this.engine = DebeziumEngine.create(Json.class) + .using(properties) + .notifying(record -> processRecord(record, collector)) + .build(); + + this.executorService = Executors.newSingleThreadExecutor(); + this.executorService.execute(engine); + } + + @Override + public void onAdapterStopped(IAdapterParameterExtractor extractor, + IAdapterRuntimeContext adapterRuntimeContext) throws AdapterException { + if (engine != null) { + try { + engine.close(); + } catch (IOException e) { + throw new AdapterException("Failed to stop Debezium engine: " + e.getMessage(), e); + } + } + + if (executorService != null) { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(15, TimeUnit.SECONDS)) { + LOG.warn("Debezium engine executor did not shut down within 15 seconds."); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AdapterException("Interrupted while waiting for Debezium engine shutdown.", e); + } + } + + this.engine = null; + this.executorService = null; + this.temporalColumns = null; + this.configuredZoneId = null; + } + + @Override + public SampleData onSampleDataRequested(IAdapterParameterExtractor extractor, + IAdapterGuessSchemaContext adapterGuessSchemaContext) throws AdapterException { + MsSqlCdcAdapterConfig config = MsSqlCdcAdapterConfig.from(extractor.getStaticPropertyExtractor()); + metadataClient.validateSelectedTable(config); + return SampleDataBuilder.create() + .sample(metadataClient.generateSample(config)) + .build(); + } + + private Properties buildDebeziumProperties(MsSqlCdcAdapterConfig config) { + Properties props = new Properties(); + String logicalName = createLogicalName(config); + props.setProperty("name", logicalName + "-engine"); + props.setProperty("connector.class", "io.debezium.connector.sqlserver.SqlServerConnector"); + props.setProperty("offset.storage", "org.apache.kafka.connect.storage.MemoryOffsetBackingStore"); + props.setProperty("schema.history.internal", "io.debezium.relational.history.MemorySchemaHistory"); + props.setProperty("converter.schemas.enable", "false"); + props.setProperty("key.converter.schemas.enable", "false"); + props.setProperty("value.converter.schemas.enable", "false"); + props.setProperty("include.schema.changes", "false"); + props.setProperty("tombstones.on.delete", "false"); + props.setProperty("snapshot.mode", "no_data"); + props.setProperty("tasks.max", "1"); + props.setProperty("decimal.handling.mode", "double"); + + props.setProperty("database.hostname", config.getHost()); + props.setProperty("database.port", String.valueOf(config.getPort())); + props.setProperty("database.user", config.getUsername()); + props.setProperty("database.password", config.getPassword()); + props.setProperty("database.names", config.getDatabase()); + props.setProperty("database.encrypt", String.valueOf(config.getEncrypt())); + props.setProperty("database.trustServerCertificate", String.valueOf(config.getTrustServerCertificate())); + props.setProperty("topic.prefix", logicalName); + props.setProperty("table.include.list", config.getTable()); + + return props; + } + + private String createLogicalName(MsSqlCdcAdapterConfig config) { + return (config.getDatabase() + "_" + config.getTable()) + .replace('.', '_') + .replaceAll("[^A-Za-z0-9_]", "_") + .toLowerCase(); + } + + private void processRecord(ChangeEvent<String, String> record, IEventCollector collector) { + if (record == null || record.value() == null) { + return; + } + + try { + JsonNode root = mapper.readTree(record.value()); + if (!"c".equals(root.path("op").asText())) { + return; + } + + JsonNode afterNode = root.path("after"); + if (!afterNode.isObject()) { + return; + } + + collector.collect(normalizeRow(mapper.convertValue(afterNode, MAP_TYPE))); + } catch (Exception e) { + LOG.error("Failed to process Debezium change event", e); + } + } + + private Map<String, Object> normalizeRow(Map<String, Object> row) { + if (row == null || row.isEmpty() || temporalColumns == null || temporalColumns.isEmpty()) { + return row; + } + + Map<String, Object> normalized = new LinkedHashMap<>(row); + temporalColumns.forEach((columnName, temporalMode) -> { + if (normalized.containsKey(columnName)) { + normalized.put(columnName, normalizeTemporalValue(normalized.get(columnName), temporalMode)); + } + }); + return normalized; + } + + private Object normalizeTemporalValue(Object value, MsSqlMetadataClient.TemporalColumnMode temporalMode) { + if (value == null) { + return null; + } + + return switch (temporalMode) { + case DATE_EPOCH_MILLIS -> normalizeDateValue(asLong(value)); + case TIME_MILLIS -> asLong(value); + case TIME_MICROS -> asLong(value) / 1_000L; + case TIME_NANOS -> asLong(value) / 1_000_000L; + case TIMESTAMP_MILLIS -> normalizeTimestampValue(asLong(value), 1L); + case TIMESTAMP_MICROS -> normalizeTimestampValue(asLong(value), 1_000L); + case TIMESTAMP_NANOS -> normalizeTimestampValue(asLong(value), 1_000_000L); + case DATETIMEOFFSET_MILLIS -> parseOffsetDateTime(value); + }; + } + + private long normalizeDateValue(long epochDays) { + LocalDate localDate = LocalDate.ofEpochDay(epochDays); + return localDate.atStartOfDay(getConfiguredZoneId()).toInstant().toEpochMilli(); + } + + private long normalizeTimestampValue(long rawValue, long divisor) { + long epochMillis = rawValue / divisor; + LocalDateTime localDateTime = Instant.ofEpochMilli(epochMillis).atOffset(ZoneOffset.UTC).toLocalDateTime(); + return localDateTime.atZone(getConfiguredZoneId()).toInstant().toEpochMilli(); + } + + private long asLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + + return Long.parseLong(String.valueOf(value)); + } + + private Object parseOffsetDateTime(Object value) { + try { + return OffsetDateTime.parse(String.valueOf(value)).toInstant().toEpochMilli(); + } catch (DateTimeParseException e) { + LOG.warn("Failed to parse DATETIMEOFFSET value '{}', forwarding raw value.", value); + return value; + } + } + + private ZoneId getConfiguredZoneId() { + return configuredZoneId == null ? ZoneOffset.UTC : configuredZoneId; + } +} diff --git a/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapterConfig.java b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapterConfig.java new file mode 100644 index 0000000000..3d1374067a --- /dev/null +++ b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlCdcAdapterConfig.java @@ -0,0 +1,143 @@ +/* + * 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.extractor.IStaticPropertyExtractor; +import org.apache.streampipes.model.staticproperty.Option; +import org.apache.streampipes.model.staticproperty.RuntimeResolvableOneOfStaticProperty; + +import java.time.ZoneId; +import java.time.ZoneOffset; + +public class MsSqlCdcAdapterConfig { + + public static final String HOST_KEY = "host"; + public static final String PORT_KEY = "port"; + public static final String DATABASE_KEY = "database"; + public static final String USERNAME_KEY = "username"; + public static final String PASSWORD_KEY = "password"; + public static final String TABLE_KEY = "table"; + public static final String ENCRYPT_KEY = "encrypt"; + public static final String TRUST_SERVER_CERTIFICATE_KEY = "trust-server-certificate"; + public static final String TIMEZONE_KEY = "timezone"; + + private final String host; + private final Integer port; + private final String database; + private final String username; + private final String password; + private final String table; + private final Boolean encrypt; + private final Boolean trustServerCertificate; + private final String timezone; + + public MsSqlCdcAdapterConfig(String host, + Integer port, + String database, + String username, + String password, + String table, + Boolean encrypt, + Boolean trustServerCertificate, + String timezone) { + this.host = host; + this.port = port; + this.database = database; + this.username = username; + this.password = password; + this.table = table; + this.encrypt = encrypt; + this.trustServerCertificate = trustServerCertificate; + this.timezone = timezone; + } + + public static MsSqlCdcAdapterConfig from(IStaticPropertyExtractor extractor) { + return from(extractor, true); + } + + public static MsSqlCdcAdapterConfig from(IStaticPropertyExtractor extractor, boolean requireTableSelection) { + return new MsSqlCdcAdapterConfig( + extractor.singleValueParameter(HOST_KEY, String.class), + extractor.singleValueParameter(PORT_KEY, Integer.class), + extractor.singleValueParameter(DATABASE_KEY, String.class), + extractor.singleValueParameter(USERNAME_KEY, String.class), + extractor.secretValue(PASSWORD_KEY), + extractTable(extractor, requireTableSelection), + extractor.slideToggleValue(ENCRYPT_KEY), + extractor.slideToggleValue(TRUST_SERVER_CERTIFICATE_KEY), + extractor.singleValueParameter(TIMEZONE_KEY, String.class) + ); + } + + private static String extractTable(IStaticPropertyExtractor extractor, boolean requireTableSelection) { + if (requireTableSelection) { + return extractor.selectedSingleValue(TABLE_KEY, String.class); + } + + RuntimeResolvableOneOfStaticProperty tableProperty = + extractor.getStaticPropertyByName(TABLE_KEY, RuntimeResolvableOneOfStaticProperty.class); + + return tableProperty.getOptions() + .stream() + .filter(Option::isSelected) + .findFirst() + .map(Option::getName) + .orElse(null); + } + + public String getHost() { + return host; + } + + public Integer getPort() { + return port; + } + + public String getDatabase() { + return database; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getTable() { + return table; + } + + public Boolean getEncrypt() { + return encrypt; + } + + public Boolean getTrustServerCertificate() { + return trustServerCertificate; + } + + public String getTimezone() { + return timezone; + } + + public ZoneId getZoneId() { + return timezone == null || timezone.isBlank() ? ZoneOffset.UTC : ZoneId.of(timezone); + } +} 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 new file mode 100644 index 0000000000..4d4d07d582 --- /dev/null +++ b/streampipes-extensions/streampipes-connectors-cdc/src/main/java/org/apache/streampipes/extensions/connectors/cdc/adapter/mssql/MsSqlMetadataClient.java @@ -0,0 +1,266 @@ +/* + * 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.commons.exceptions.SpConfigurationException; +import org.apache.streampipes.commons.exceptions.connect.AdapterException; +import org.apache.streampipes.model.staticproperty.Option; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class MsSqlMetadataClient { + + private static final String CDC_ENABLED_QUERY = + "SELECT is_cdc_enabled FROM sys.databases WHERE name = DB_NAME()"; + + private static final String CDC_TABLES_QUERY = + "SELECT s.name AS schema_name, t.name AS table_name " + + "FROM sys.tables t " + + "JOIN sys.schemas s ON t.schema_id = s.schema_id " + + "WHERE t.is_ms_shipped = 0 AND t.is_tracked_by_cdc = 1 " + + "ORDER BY s.name, t.name"; + + public List<Option> discoverTables(MsSqlCdcAdapterConfig config) throws SpConfigurationException { + try (Connection connection = openConnection(config)) { + validateCdcEnabled(connection); + + List<Option> options = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(CDC_TABLES_QUERY); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + options.add(new Option(resultSet.getString("schema_name") + "." + resultSet.getString("table_name"))); + } + } + + if (options.isEmpty()) { + throw new SpConfigurationException("No CDC-enabled tables found in database " + config.getDatabase()); + } + + return options; + } catch (SQLException e) { + throw new SpConfigurationException("Failed to resolve SQL Server tables: " + e.getMessage(), e); + } + } + + public void validateSelectedTable(MsSqlCdcAdapterConfig config) throws AdapterException { + try (Connection connection = openConnection(config)) { + validateCdcEnabled(connection); + + List<Option> options = discoverTables(config); + boolean containsTable = options.stream().anyMatch(option -> option.getName().equals(config.getTable())); + if (!containsTable) { + throw new AdapterException("Selected table is not CDC-enabled or does not exist: " + config.getTable()); + } + } catch (SQLException e) { + throw new AdapterException("Failed to validate SQL Server configuration: " + e.getMessage(), e); + } catch (SpConfigurationException e) { + throw new AdapterException(e.getMessage(), e); + } + } + + public Map<String, Object> generateSample(MsSqlCdcAdapterConfig config) throws AdapterException { + TableRef tableRef = parseTable(config.getTable()); + + try (Connection connection = openConnection(config)) { + 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))); + + if (sample.isEmpty()) { + throw new AdapterException("No columns found for table " + config.getTable()); + } + + return sample; + } catch (SQLException | SpConfigurationException e) { + throw new AdapterException("Failed to generate sample data: " + e.getMessage(), e); + } + } + + public Connection openConnection(MsSqlCdcAdapterConfig config) throws SQLException { + String url = "jdbc:sqlserver://" + config.getHost() + ":" + config.getPort() + + ";databaseName=" + config.getDatabase() + + ";encrypt=" + config.getEncrypt() + + ";trustServerCertificate=" + config.getTrustServerCertificate(); + + return DriverManager.getConnection(url, config.getUsername(), config.getPassword()); + } + + public Map<String, TemporalColumnMode> describeTemporalColumns(MsSqlCdcAdapterConfig config) throws AdapterException { + TableRef tableRef = parseTable(config.getTable()); + + try (Connection connection = openConnection(config)) { + validateCdcEnabled(connection); + + Map<String, TemporalColumnMode> temporalColumns = new LinkedHashMap<>(); + Map<String, ColumnDescriptor> columns = describeColumns(connection, tableRef); + columns.forEach((columnName, descriptor) -> { + TemporalColumnMode temporalMode = getTemporalColumnMode(descriptor); + if (temporalMode != null) { + temporalColumns.put(columnName, temporalMode); + } + }); + return temporalColumns; + } catch (SQLException | SpConfigurationException e) { + throw new AdapterException("Failed to inspect SQL Server table metadata: " + e.getMessage(), e); + } + } + + private void validateCdcEnabled(Connection connection) throws SQLException, SpConfigurationException { + try (PreparedStatement statement = connection.prepareStatement(CDC_ENABLED_QUERY); + ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next() || !resultSet.getBoolean(1)) { + throw new SpConfigurationException("CDC is not enabled on database " + connection.getCatalog()); + } + } + } + + private TableRef parseTable(String table) throws AdapterException { + if (table == null || table.isBlank()) { + throw new AdapterException("No table selected."); + } + + String[] tokens = table.split("\\.", 2); + if (tokens.length != 2) { + throw new AdapterException("Selected table must be of the form schema.table: " + table); + } + + return new TableRef(tokens[0], tokens[1]); + } + + private Map<String, ColumnDescriptor> describeColumns(Connection connection, + TableRef tableRef) throws SQLException { + DatabaseMetaData metaData = connection.getMetaData(); + Map<String, ColumnDescriptor> columns = new LinkedHashMap<>(); + try (ResultSet resultSet = metaData.getColumns(null, tableRef.schema(), tableRef.table(), null)) { + while (resultSet.next()) { + String columnName = resultSet.getString("COLUMN_NAME"); + int jdbcType = resultSet.getInt("DATA_TYPE"); + String typeName = resultSet.getString("TYPE_NAME"); + int decimalDigits = resultSet.getInt("DECIMAL_DIGITS"); + columns.put(columnName, new ColumnDescriptor(columnName, jdbcType, typeName, decimalDigits)); + } + } + return columns; + } + + private Object placeholderForType(ColumnDescriptor descriptor, ZoneId zoneId) { + TemporalColumnMode temporalMode = getTemporalColumnMode(descriptor); + if (temporalMode != null) { + return sampleTemporalValue(temporalMode, zoneId); + } + + return switch (descriptor.jdbcType()) { + case Types.BIGINT, Types.INTEGER, Types.SMALLINT, Types.TINYINT -> 1; + case Types.DECIMAL, Types.DOUBLE, Types.FLOAT, Types.NUMERIC, Types.REAL -> 1.0; + case Types.BIT, Types.BOOLEAN -> true; + case Types.BINARY, Types.LONGVARBINARY, Types.VARBINARY -> "AQID"; + default -> "example"; + }; + } + + private TemporalColumnMode getTemporalColumnMode(ColumnDescriptor descriptor) { + String normalizedTypeName = descriptor.typeName() == null + ? "" + : descriptor.typeName().toLowerCase(Locale.ENGLISH); + + return switch (normalizedTypeName) { + case "date" -> TemporalColumnMode.DATE_EPOCH_MILLIS; + case "time" -> { + if (descriptor.decimalDigits() <= 3) { + yield TemporalColumnMode.TIME_MILLIS; + } else if (descriptor.decimalDigits() <= 6) { + yield TemporalColumnMode.TIME_MICROS; + } else { + yield TemporalColumnMode.TIME_NANOS; + } + } + case "datetime", "smalldatetime" -> TemporalColumnMode.TIMESTAMP_MILLIS; + case "datetime2" -> { + if (descriptor.decimalDigits() <= 3) { + yield TemporalColumnMode.TIMESTAMP_MILLIS; + } else if (descriptor.decimalDigits() <= 6) { + yield TemporalColumnMode.TIMESTAMP_MICROS; + } else { + yield TemporalColumnMode.TIMESTAMP_NANOS; + } + } + case "datetimeoffset" -> TemporalColumnMode.DATETIMEOFFSET_MILLIS; + default -> { + if (descriptor.jdbcType() == Types.DATE) { + yield TemporalColumnMode.DATE_EPOCH_MILLIS; + } else if (descriptor.jdbcType() == Types.TIME || descriptor.jdbcType() == Types.TIME_WITH_TIMEZONE) { + yield TemporalColumnMode.TIME_MILLIS; + } else if (descriptor.jdbcType() == Types.TIMESTAMP + || descriptor.jdbcType() == Types.TIMESTAMP_WITH_TIMEZONE) { + yield TemporalColumnMode.TIMESTAMP_MILLIS; + } + yield null; + } + }; + } + + private Object sampleTemporalValue(TemporalColumnMode temporalMode, ZoneId zoneId) { + return switch (temporalMode) { + case DATE_EPOCH_MILLIS -> + LocalDate.of(2026, 5, 7).atStartOfDay(zoneId).toInstant().toEpochMilli(); + case TIME_MILLIS, TIME_MICROS, TIME_NANOS -> + LocalTime.of(10, 15, 30, 123_000_000).toNanoOfDay() / 1_000_000; + case TIMESTAMP_MILLIS, TIMESTAMP_MICROS, TIMESTAMP_NANOS, DATETIMEOFFSET_MILLIS -> + LocalDate.of(2026, 5, 7) + .atTime(10, 15, 30, 123_000_000) + .atZone(zoneId) + .toInstant() + .toEpochMilli(); + }; + } + + private record TableRef(String schema, String table) { + } + + public enum TemporalColumnMode { + DATE_EPOCH_MILLIS, + TIME_MILLIS, + TIME_MICROS, + TIME_NANOS, + TIMESTAMP_MILLIS, + TIMESTAMP_MICROS, + TIMESTAMP_NANOS, + DATETIMEOFFSET_MILLIS + } + + private record ColumnDescriptor(String columnName, int jdbcType, String typeName, int decimalDigits) { + } +} 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 new file mode 100644 index 0000000000..c73518e0f7 --- /dev/null +++ b/streampipes-extensions/streampipes-connectors-cdc/src/main/resources/org.apache.streampipes.connect.cdc.adapter.mssql/strings.en @@ -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. +# + + +org.apache.streampipes.connect.cdc.adapter.mssql.title=Microsoft SQL Server CDC +org.apache.streampipes.connect.cdc.adapter.mssql.description=Captures newly inserted rows from a CDC-enabled Microsoft SQL Server table + +host.title=Host +host.description=SQL Server hostname or IP address + +port.title=Port +port.description=SQL Server TCP port + +database.title=Database +database.description=Name of the CDC-enabled SQL Server database + +username.title=Username +username.description=SQL Server user name + +password.title=Password +password.description=SQL Server password + +encrypt.title=Encrypt Connection +encrypt.description=Use TLS when connecting to SQL Server + +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 + +table.title=Table +table.description=Select a CDC-enabled table from the configured database diff --git a/streampipes-extensions/streampipes-extensions-all-iiot/pom.xml b/streampipes-extensions/streampipes-extensions-all-iiot/pom.xml index 4507aff55c..319995a00b 100644 --- a/streampipes-extensions/streampipes-extensions-all-iiot/pom.xml +++ b/streampipes-extensions/streampipes-extensions-all-iiot/pom.xml @@ -197,6 +197,12 @@ <version>0.99.0-SNAPSHOT</version> </dependency> + <dependency> + <groupId>org.apache.streampipes</groupId> + <artifactId>streampipes-connectors-cdc</artifactId> + <version>0.99.0-SNAPSHOT</version> + </dependency> + <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> diff --git a/streampipes-extensions/streampipes-extensions-all-iiot/src/main/java/org/apache/streampipes/extensions/all/iiot/AllExtensionsIIoTInit.java b/streampipes-extensions/streampipes-extensions-all-iiot/src/main/java/org/apache/streampipes/extensions/all/iiot/AllExtensionsIIoTInit.java index fb64c58b37..9290d205da 100644 --- a/streampipes-extensions/streampipes-extensions-all-iiot/src/main/java/org/apache/streampipes/extensions/all/iiot/AllExtensionsIIoTInit.java +++ b/streampipes-extensions/streampipes-extensions-all-iiot/src/main/java/org/apache/streampipes/extensions/all/iiot/AllExtensionsIIoTInit.java @@ -20,6 +20,7 @@ package org.apache.streampipes.extensions.all.iiot; import org.apache.streampipes.connect.iiot.IIoTAdaptersExtensionModuleExport; import org.apache.streampipes.connectors.ros.RosConnectorsModuleExport; +import org.apache.streampipes.extensions.connectors.cdc.CdcConnectorsModuleExport; import org.apache.streampipes.extensions.connectors.influx.InfluxConnectorsModuleExport; import org.apache.streampipes.extensions.connectors.kafka.KafkaConnectorsModuleExport; import org.apache.streampipes.extensions.connectors.mqtt.MqttConnectorsModuleExport; @@ -85,7 +86,8 @@ public class AllExtensionsIIoTInit extends StreamPipesExtensionsServiceBase { new DatabaseSinksExtensionModuleExport(), new InternalSinksExtensionModuleExports(), new NotificationsExtensionModuleExport(), - new LlmExtensionModuleExport() + new LlmExtensionModuleExport(), + new CdcConnectorsModuleExport() ) .registerRuntimeProvider(new StandaloneStreamPipesRuntimeProvider()) .registerMessagingProtocols( diff --git a/streampipes-extensions/streampipes-extensions-all-jvm/pom.xml b/streampipes-extensions/streampipes-extensions-all-jvm/pom.xml index f6da620463..4e30d10153 100644 --- a/streampipes-extensions/streampipes-extensions-all-jvm/pom.xml +++ b/streampipes-extensions/streampipes-extensions-all-jvm/pom.xml @@ -74,6 +74,11 @@ <artifactId>streampipes-connectors-camel-azure</artifactId> <version>0.99.0-SNAPSHOT</version> </dependency> + <dependency> + <groupId>org.apache.streampipes</groupId> + <artifactId>streampipes-connectors-cdc</artifactId> + <version>0.99.0-SNAPSHOT</version> + </dependency> <dependency> <groupId>org.apache.streampipes</groupId> <artifactId>streampipes-connectors-influx</artifactId> diff --git a/streampipes-extensions/streampipes-extensions-all-jvm/src/main/java/org/apache/streampipes/extensions/all/jvm/AllExtensionsInit.java b/streampipes-extensions/streampipes-extensions-all-jvm/src/main/java/org/apache/streampipes/extensions/all/jvm/AllExtensionsInit.java index e39dbdf0d8..ab7f2d6ea3 100644 --- a/streampipes-extensions/streampipes-extensions-all-jvm/src/main/java/org/apache/streampipes/extensions/all/jvm/AllExtensionsInit.java +++ b/streampipes-extensions/streampipes-extensions-all-jvm/src/main/java/org/apache/streampipes/extensions/all/jvm/AllExtensionsInit.java @@ -21,6 +21,7 @@ import org.apache.streampipes.connect.GeneralAdaptersExtensionModuleExport; import org.apache.streampipes.connect.iiot.IIoTAdaptersExtensionModuleExport; import org.apache.streampipes.connectors.ros.RosConnectorsModuleExport; import org.apache.streampipes.extensions.connectors.camel.azure.CamelAzureConnectorsModuleExport; +import org.apache.streampipes.extensions.connectors.cdc.CdcConnectorsModuleExport; import org.apache.streampipes.extensions.connectors.influx.InfluxConnectorsModuleExport; import org.apache.streampipes.extensions.connectors.kafka.KafkaConnectorsModuleExport; import org.apache.streampipes.extensions.connectors.mqtt.MqttConnectorsModuleExport; @@ -69,6 +70,7 @@ public class AllExtensionsInit extends StreamPipesExtensionsServiceBase { new IIoTAdaptersExtensionModuleExport(), new CamelAzureConnectorsModuleExport(), + new CdcConnectorsModuleExport(), new InfluxConnectorsModuleExport(), new KafkaConnectorsModuleExport(), new MqttConnectorsModuleExport(), diff --git a/ui/src/app/core-ui/static-properties/static-runtime-resolvable-oneof-input/static-runtime-resolvable-oneof-input.component.ts b/ui/src/app/core-ui/static-properties/static-runtime-resolvable-oneof-input/static-runtime-resolvable-oneof-input.component.ts index 2dbb534d10..203439b179 100644 --- a/ui/src/app/core-ui/static-properties/static-runtime-resolvable-oneof-input/static-runtime-resolvable-oneof-input.component.ts +++ b/ui/src/app/core-ui/static-properties/static-runtime-resolvable-oneof-input/static-runtime-resolvable-oneof-input.component.ts @@ -79,6 +79,7 @@ export class StaticRuntimeResolvableOneOfInputComponent } } this.staticProperty.options = staticProperty.options; + this.performValidation(); } isOptionSelected(): boolean {
