This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 4c6bdcf8de Issue #8138 : Do not send a path option when writing with
file_format=jdbc (#8176)
4c6bdcf8de is described below
commit 4c6bdcf8de670ec92ef73636d113ee029126081a
Author: vbhanuchander-lang <[email protected]>
AuthorDate: Mon Aug 31 03:40:48 2026 -0400
Issue #8138 : Do not send a path option when writing with file_format=jdbc
(#8176)
SparkFileIoSupport.writeDataset always called DataFrameWriter.save(path).
That one-argument overload stores its argument as the "path" writer option
before saving, and Spark's JDBC provider forwards every option it does not
recognise to the driver as a connection property. A JDBC sink has no path,
so the driver receives one it never asked for.
Postgres and MySQL ignore properties they do not know, so nothing surfaces
there. Teradata validates them and fails the write outright with
"Invalid connection parameter name path".
- Writing a pathless format now calls the no-argument save(), so the path
never enters the writer options. Only jdbc is pathless today; the set is
named so other option-addressed sinks can join it.
- The error message for those formats no longer quotes a path that played
no part in the write.
- SparkFileOutput no longer demands a file path when the format is
pathless. It previously rejected the transform before it ran, forcing
users to invent a path that was then sent to the driver as the property
causing the failure.
The lake table writer shares writeDataset but only ever passes delta or
iceberg, both path-based, so it is unaffected.
Tests capture the connection properties Spark builds via a delegating
driver and assert "path" is absent, rather than depending on a driver
strict enough to reject it - H2, like Postgres and MySQL, ignores it.
---
plugins/engines/spark/pom.xml | 15 ++
.../spark/pipeline/handler/SparkFileIoSupport.java | 27 +++-
.../pipeline/handler/SparkFileOutputHandler.java | 4 +-
.../pipeline/handler/CapturingJdbcDriver.java | 87 +++++++++++
.../pipeline/handler/SparkFileIoJdbcWriteTest.java | 168 +++++++++++++++++++++
5 files changed, 299 insertions(+), 2 deletions(-)
diff --git a/plugins/engines/spark/pom.xml b/plugins/engines/spark/pom.xml
index 8f90135bcf..f456a16483 100644
--- a/plugins/engines/spark/pom.xml
+++ b/plugins/engines/spark/pom.xml
@@ -52,6 +52,13 @@
<type>pom</type>
<scope>import</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.hop</groupId>
+ <artifactId>hop-libs-jdbc</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>import</scope>
+ </dependency>
</dependencies>
</dependencyManagement>
@@ -196,6 +203,14 @@
</exclusion>
</exclusions>
</dependency>
+ <!-- Real backing database for the jdbc write tests (issue #8138). H2
ignores unknown
+ connection properties, so the tests assert what Spark hands the
driver rather than
+ relying on a driver being strict enough to complain. -->
+ <dependency>
+ <groupId>com.h2database</groupId>
+ <artifactId>h2</artifactId>
+ <scope>test</scope>
+ </dependency>
<!-- Filter Rows for target-stream unit tests -->
<dependency>
<groupId>org.apache.hop</groupId>
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileIoSupport.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileIoSupport.java
index 3c3c2f1239..5450d6d082 100644
---
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileIoSupport.java
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileIoSupport.java
@@ -19,6 +19,7 @@ package org.apache.hop.spark.pipeline.handler;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.variables.IVariables;
@@ -34,6 +35,20 @@ public final class SparkFileIoSupport {
private SparkFileIoSupport() {}
+ /**
+ * Formats whose sink is not addressed by a path. Spark's {@code
DataFrameWriter.save(String)}
+ * stores its argument as the {@code path} option before saving, and the
JDBC provider forwards
+ * every option it does not recognise to the driver as a connection
property. Drivers that
+ * validate connection properties (Teradata) then reject {@code path}
outright, while Postgres and
+ * MySQL silently ignore it — which is why this only surfaces on some
databases (issue #8138).
+ */
+ private static final Set<String> PATHLESS_FORMATS = Set.of("jdbc");
+
+ /** Whether writing this format should omit the path rather than pass it to
Spark as an option. */
+ public static boolean isPathless(String format) {
+ return format != null &&
PATHLESS_FORMATS.contains(normalizeFormat(format));
+ }
+
public static String normalizeFormat(String format) {
if (StringUtils.isEmpty(format)) {
return SparkFileInputMeta.FORMAT_CSV;
@@ -94,9 +109,19 @@ public final class SparkFileIoSupport {
if (partitionColumns != null && partitionColumns.length > 0) {
writer = writer.partitionBy(partitionColumns);
}
+ boolean pathless = isPathless(format);
try {
- writer.save(path);
+ if (pathless) {
+ // Passing the path here would land in the writer options and reach
the JDBC driver as a
+ // connection property (issue #8138).
+ writer.save();
+ } else {
+ writer.save(path);
+ }
} catch (Exception e) {
+ if (pathless) {
+ throw new HopException("Error writing Spark Dataset as " + format, e);
+ }
throw new HopException(
SparkPathDialect.withPathHint(
"Error writing Spark Dataset to '" + path + "' as " + format,
path),
diff --git
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileOutputHandler.java
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileOutputHandler.java
index 72388cb338..587456adc3 100644
---
a/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileOutputHandler.java
+++
b/plugins/engines/spark/src/main/java/org/apache/hop/spark/pipeline/handler/SparkFileOutputHandler.java
@@ -90,7 +90,9 @@ public class SparkFileOutputHandler extends
SparkBaseTransformHandler {
}
String resolved = variables.resolve(meta.getFilePath());
String path = SparkPathDialect.toSparkUri(resolved, runConfiguration);
- if (StringUtils.isEmpty(path)) {
+ // A pathless sink such as jdbc is addressed entirely through its options,
so requiring a file
+ // path would only force the user to invent one that is then ignored
(issue #8138).
+ if (StringUtils.isEmpty(path) &&
!SparkFileIoSupport.isPathless(meta.getFileFormat())) {
throw new HopException(
"Spark File Output '" + transformMeta.getName() + "' has no file
path configured");
}
diff --git
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/CapturingJdbcDriver.java
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/CapturingJdbcDriver.java
new file mode 100644
index 0000000000..e096c992c7
--- /dev/null
+++
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/CapturingJdbcDriver.java
@@ -0,0 +1,87 @@
+/*
+ * 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.hop.spark.pipeline.handler;
+
+import java.sql.Connection;
+import java.sql.Driver;
+import java.sql.DriverManager;
+import java.sql.DriverPropertyInfo;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.logging.Logger;
+
+/**
+ * Test driver that records the connection properties Spark builds and then
delegates to the real
+ * database behind the {@link #PREFIX}.
+ *
+ * <p>Used by {@link SparkFileIoJdbcWriteTest} to check directly that {@code
path} never reaches a
+ * driver (issue #8138), rather than depending on a driver being strict enough
to reject it —
+ * Teradata rejects it, but Postgres, MySQL and H2 all ignore properties they
do not recognise.
+ *
+ * <p>Top-level rather than nested on purpose: Spark's {@code DriverRegistry}
matches a registered
+ * driver by canonical name, which for a nested class does not equal the
binary name it is loaded
+ * by, and the lookup then fails with an internal error.
+ */
+public class CapturingJdbcDriver implements Driver {
+
+ public static final String PREFIX = "jdbc:capture:";
+ public static final List<Properties> CAPTURED = new CopyOnWriteArrayList<>();
+
+ @Override
+ public Connection connect(String url, Properties info) throws SQLException {
+ if (!acceptsURL(url)) {
+ return null;
+ }
+ Properties copy = new Properties();
+ copy.putAll(info);
+ CAPTURED.add(copy);
+ return DriverManager.getConnection(url.substring(PREFIX.length()), info);
+ }
+
+ @Override
+ public boolean acceptsURL(String url) {
+ return url != null && url.startsWith(PREFIX);
+ }
+
+ @Override
+ public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) {
+ return new DriverPropertyInfo[0];
+ }
+
+ @Override
+ public int getMajorVersion() {
+ return 1;
+ }
+
+ @Override
+ public int getMinorVersion() {
+ return 0;
+ }
+
+ @Override
+ public boolean jdbcCompliant() {
+ return false;
+ }
+
+ @Override
+ public Logger getParentLogger() {
+ return Logger.getLogger("hop-8138-capturing-driver");
+ }
+}
diff --git
a/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/SparkFileIoJdbcWriteTest.java
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/SparkFileIoJdbcWriteTest.java
new file mode 100644
index 0000000000..9ec7f1d926
--- /dev/null
+++
b/plugins/engines/spark/src/test/java/org/apache/hop/spark/pipeline/handler/SparkFileIoJdbcWriteTest.java
@@ -0,0 +1,168 @@
+/*
+ * 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.hop.spark.pipeline.handler;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Properties;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SaveMode;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Issue #8138: writing with {@code file_format=jdbc} must not hand Spark a
path.
+ *
+ * <p>{@code DataFrameWriter.save(String)} records its argument as the {@code
path} option, and the
+ * JDBC provider passes every option it does not recognise to the driver as a
connection property.
+ * Teradata validates connection properties and rejects {@code path};
Postgres, MySQL and H2 all
+ * ignore properties they do not know, which is why the bug is invisible
against them.
+ *
+ * <p>Rather than depend on a strict driver being available, these tests
assert the thing that is
+ * actually wrong: what Spark hands the driver. {@link CapturingJdbcDriver}
records the {@link
+ * Properties} of every connection and delegates to H2, so the absence of
{@code path} is checked
+ * directly instead of inferred from whether some driver happened to complain.
+ */
+class SparkFileIoJdbcWriteTest {
+
+ private static SparkSession spark;
+ private static final String H2_URL = "jdbc:h2:mem:hop8138;DB_CLOSE_DELAY=-1";
+ private static final String URL = CapturingJdbcDriver.PREFIX + H2_URL;
+
+ @BeforeAll
+ static void start() throws Exception {
+ HopEnvironment.init();
+ HopLogStore.init();
+ spark =
+ SparkSession.builder()
+ .appName("hop-spark-jdbc-write-test")
+ .master("local[2]")
+ .config("spark.ui.enabled", "false")
+ .config("spark.ui.showConsoleProgress", "false")
+ .config("spark.metrics.staticSources.enabled", "false")
+ .config("spark.sql.shuffle.partitions", "2")
+ .config("spark.driver.host", "localhost")
+ .getOrCreate();
+ DriverManager.registerDriver(new CapturingJdbcDriver());
+ // Keep the in-memory database alive for the duration of the test class.
+ DriverManager.getConnection(H2_URL).close();
+ }
+
+ @AfterAll
+ static void stop() {
+ if (spark != null) {
+ spark.stop();
+ }
+ }
+
+ private Dataset<Row> sampleRows() {
+ StructType schema =
+ DataTypes.createStructType(
+ new org.apache.spark.sql.types.StructField[] {
+ DataTypes.createStructField("id", DataTypes.IntegerType, false),
+ DataTypes.createStructField("name", DataTypes.StringType, true)
+ });
+ return spark.createDataFrame(
+ java.util.List.of(RowFactory.create(1, "alpha"), RowFactory.create(2,
"beta")), schema);
+ }
+
+ private Map<String, String> jdbcOptions(String table) {
+ Map<String, String> options = new LinkedHashMap<>();
+ options.put("url", URL);
+ options.put("dbtable", table);
+ options.put("driver", CapturingJdbcDriver.class.getName());
+ return options;
+ }
+
+ /**
+ * The reported defect: the meaningless file path the transform forces the
user to invent must not
+ * reach the driver as a connection property. Fails before the fix with
{@code path} present.
+ */
+ @Test
+ void jdbcWriteDoesNotSendThePathAsAConnectionProperty() throws Exception {
+ CapturingJdbcDriver.CAPTURED.clear();
+ SparkFileIoSupport.writeDataset(
+ sampleRows(),
+ "jdbc",
+ "/tmp/hop-bug-repro/category_sales_summary",
+ SaveMode.Overwrite,
+ jdbcOptions("SALES_SUMMARY"),
+ null,
+ null);
+
+ assertFalse(
+ CapturingJdbcDriver.CAPTURED.isEmpty(), "the driver was never asked
for a connection");
+ for (Properties p : CapturingJdbcDriver.CAPTURED) {
+ assertFalse(
+ p.containsKey("path"),
+ "Spark sent 'path' as a connection property: " +
p.stringPropertyNames());
+ }
+
+ try (Connection c = DriverManager.getConnection(H2_URL);
+ Statement st = c.createStatement();
+ ResultSet rs = st.executeQuery("select count(*) from SALES_SUMMARY")) {
+ assertTrue(rs.next());
+ assertEquals(2, rs.getInt(1));
+ }
+ }
+
+ /** The same write with no path at all must behave identically — nothing
depends on the path. */
+ @Test
+ void jdbcWriteWorksWithNoPathConfiguredAtAll() throws Exception {
+ SparkFileIoSupport.writeDataset(
+ sampleRows(), "jdbc", "", SaveMode.Overwrite, jdbcOptions("NO_PATH"),
null, null);
+
+ try (Connection c = DriverManager.getConnection(H2_URL);
+ Statement st = c.createStatement();
+ ResultSet rs = st.executeQuery("select count(*) from NO_PATH")) {
+ assertTrue(rs.next());
+ assertEquals(2, rs.getInt(1));
+ }
+ }
+
+ /** Path-based formats keep receiving their path; the change must not reach
them. */
+ @Test
+ void fileFormatsAreStillPathBased() {
+ assertTrue(SparkFileIoSupport.isPathless("jdbc"));
+ assertTrue(SparkFileIoSupport.isPathless("JDBC"));
+ assertTrue(SparkFileIoSupport.isPathless(" jdbc "));
+ assertFalse(SparkFileIoSupport.isPathless("csv"));
+ assertFalse(SparkFileIoSupport.isPathless("parquet"));
+ assertFalse(SparkFileIoSupport.isPathless("orc"));
+ assertFalse(SparkFileIoSupport.isPathless("json"));
+ assertFalse(SparkFileIoSupport.isPathless("text"));
+ assertFalse(SparkFileIoSupport.isPathless("delta"));
+ assertFalse(SparkFileIoSupport.isPathless(null));
+ }
+}