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

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/dev/pr-12529-eb2eb1ddc8da4987f8fb0f06c28797b9f78656fe
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit bf9749c191c4220b55ae5e05179fe1e71a3b1674
Author: Jast <[email protected]>
AuthorDate: Mon Oct 5 13:36:57 2026 +0000

    [Fix][Connector-V2][JDBC] Support column names containing spaces in 
named-parameter SQL (#12529)
---
 .../executor/FieldNamedPreparedStatement.java      | 102 +++++++++++++++++++--
 .../executor/FieldNamedPreparedStatementTest.java  |  90 ++++++++++++++++++
 2 files changed, 185 insertions(+), 7 deletions(-)

diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatement.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatement.java
index b001ed1f70..529d962eec 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatement.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/main/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatement.java
@@ -673,7 +673,7 @@ public class FieldNamedPreparedStatement implements 
PreparedStatement {
             }
         } else {
             HashMap<String, List<Integer>> parameterMap = new HashMap<>();
-            parsedSQL = parseNamedStatement(sql, parameterMap);
+            parsedSQL = parseNamedStatement(sql, parameterMap, fieldNames);
             // currently, the statements must contain all the field parameters
             parameterMap
                     .keySet()
@@ -707,24 +707,112 @@ public class FieldNamedPreparedStatement implements 
PreparedStatement {
 
     @VisibleForTesting
     public static String parseNamedStatement(String sql, Map<String, 
List<Integer>> paramMap) {
+        return parseNamedStatement(sql, paramMap, null);
+    }
+
+    /**
+     * Parses named parameters ({@code ":name"}) in the given statement.
+     *
+     * <p>The default tokenizer only accepts characters from the regular 
expression name class. When
+     * {@code knownParameterNames} is given (the sink schema field names), a 
name containing
+     * characters outside that class - for example a space in {@code "MY COL"} 
- is matched as a
+     * whole-name placeholder instead of being cut at the first such 
character. Names made up only
+     * of name-class characters keep going through the default tokenizer, so 
the existing behavior
+     * is unchanged.
+     *
+     * @param sql the statement that may contain named parameters
+     * @param paramMap receives each parsed parameter name and its parameter 
indexes
+     * @param knownParameterNames field names of the current schema, used as 
the allow-list for
+     *     names containing characters the default tokenizer cannot capture; 
may be null
+     */
+    @VisibleForTesting
+    public static String parseNamedStatement(
+            String sql, Map<String, List<Integer>> paramMap, String[] 
knownParameterNames) {
         Pattern pattern =
                 
Pattern.compile(":([\\p{L}\\p{Nl}\\p{Nd}\\p{Pc}\\$\\-\\.@%&*#~!?^+=<>|]+)");
         Matcher matcher = pattern.matcher(sql);
 
-        StringBuffer result = new StringBuffer();
-        int fieldIndex = 1;
+        StringBuilder result = new StringBuilder();
+        int fieldIndex = 1; // SQL statement parameter index starts from 1
+        int appendPosition = 0;
+        int searchFrom = 0;
 
-        while (matcher.find()) {
+        while (matcher.find(searchFrom)) {
             String parameterName = matcher.group(1);
+            int nameStart = matcher.start(1);
+            int nameEnd = matcher.end(1);
+            String knownParameter = matchKnownParameter(sql, nameStart, 
knownParameterNames);
+            if (knownParameter != null) {
+                parameterName = knownParameter;
+                nameEnd = nameStart + knownParameter.length();
+            }
             checkArgument(
                     !parameterName.isEmpty(),
                     "Named parameters in SQL statement must not be empty.");
             paramMap.computeIfAbsent(parameterName, n -> new 
ArrayList<>()).add(fieldIndex++);
-            matcher.appendReplacement(result, "?");
+            result.append(sql, appendPosition, matcher.start());
+            result.append('?');
+            appendPosition = nameEnd;
+            searchFrom = nameEnd;
         }
-
-        matcher.appendTail(result);
+        result.append(sql, appendPosition, sql.length());
 
         return result.toString();
     }
+
+    /**
+     * Returns the longest known parameter name that starts exactly at {@code 
offset} and contains
+     * at least one character outside the default name class, or {@code null} 
when there is no such
+     * match. The character right after a candidate match must not be a 
name-class character,
+     * otherwise the statement contains a longer different token and the 
candidate is skipped to
+     * avoid splitting it.
+     */
+    private static String matchKnownParameter(
+            String sql, int offset, String[] knownParameterNames) {
+        if (knownParameterNames == null || knownParameterNames.length == 0) {
+            return null;
+        }
+        String best = null;
+        for (String name : knownParameterNames) {
+            if (name == null || name.isEmpty() || name.indexOf(':') >= 0) {
+                continue;
+            }
+            // Names made up only of name-class characters are already handled 
by the default
+            // tokenizer and must not be hijacked by this allow-list matching.
+            if (isNameClassOnly(name) || !sql.startsWith(name, offset)) {
+                continue;
+            }
+            int end = offset + name.length();
+            if (end < sql.length() && isNameClassChar(sql.charAt(end))) {
+                continue;
+            }
+            if (best == null || name.length() > best.length()) {
+                best = name;
+            }
+        }
+        return best;
+    }
+
+    private static boolean isNameClassOnly(String name) {
+        for (int i = 0; i < name.length(); i++) {
+            if (!isNameClassChar(name.charAt(i))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** Mirrors the name character class of the default tokenizer regular 
expression. */
+    private static boolean isNameClassChar(char c) {
+        if (Character.isLetter(c)) {
+            return true;
+        }
+        int type = Character.getType(c);
+        if (type == Character.LETTER_NUMBER
+                || type == Character.DECIMAL_DIGIT_NUMBER
+                || type == Character.CONNECTOR_PUNCTUATION) {
+            return true;
+        }
+        return "$-.@%&*#~!?^+=<>|".indexOf(c) >= 0;
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatementTest.java
 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatementTest.java
index 00b21fda26..c79c4d2670 100644
--- 
a/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatementTest.java
+++ 
b/seatunnel-connectors-v2/connector-jdbc/src/test/java/org/apache/seatunnel/connectors/seatunnel/jdbc/internal/executor/FieldNamedPreparedStatementTest.java
@@ -19,12 +19,19 @@ package 
org.apache.seatunnel.connectors.seatunnel.jdbc.internal.executor;
 
 import org.junit.jupiter.api.Test;
 
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
 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 static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 public class FieldNamedPreparedStatementTest {
 
@@ -93,4 +100,87 @@ public class FieldNamedPreparedStatementTest {
         assertEquals(expectedSQL, actualSQL);
         assertTrue(paramMap.isEmpty());
     }
+
+    @Test
+    public void testParseNamedStatementWithSpacesInColumnNames() {
+        String sql = "INSERT INTO test (first_name, last_name) VALUES (:first 
name, :last name)";
+        String[] fieldNames = new String[] {"first name", "last name"};
+        String expectedSQL = "INSERT INTO test (first_name, last_name) VALUES 
(?, ?)";
+
+        Map<String, List<Integer>> paramMap = new HashMap<>();
+        String actualSQL =
+                FieldNamedPreparedStatement.parseNamedStatement(sql, paramMap, 
fieldNames);
+
+        assertEquals(expectedSQL, actualSQL);
+        assertTrue(paramMap.containsKey("first name"));
+        assertTrue(paramMap.containsKey("last name"));
+        assertEquals(1, paramMap.get("first name").get(0).intValue());
+        assertEquals(2, paramMap.get("last name").get(0).intValue());
+    }
+
+    @Test
+    public void 
testParseNamedStatementWithSpacesKeepsDefaultBehaviorWithoutKnownNames() {
+        // Without the known field names the default tokenizer must behave 
exactly as before:
+        // it cuts the token at the first character outside the name class.
+        String sql = "INSERT INTO test VALUES (:first name)";
+        Map<String, List<Integer>> paramMap = new HashMap<>();
+
+        String actualSQL = 
FieldNamedPreparedStatement.parseNamedStatement(sql, paramMap);
+
+        assertEquals("INSERT INTO test VALUES (? name)", actualSQL);
+        assertTrue(paramMap.containsKey("first"));
+        assertFalse(paramMap.containsKey("first name"));
+    }
+
+    @Test
+    public void testParseNamedStatementWithRepeatedSpacesInColumnNames() {
+        String sql =
+                "INSERT INTO log (message, user, message_backup) VALUES (:user 
message, :user, :user message)";
+        String[] fieldNames = new String[] {"user message", "user"};
+        String expectedSQL = "INSERT INTO log (message, user, message_backup) 
VALUES (?, ?, ?)";
+
+        Map<String, List<Integer>> paramMap = new HashMap<>();
+        String actualSQL =
+                FieldNamedPreparedStatement.parseNamedStatement(sql, paramMap, 
fieldNames);
+
+        assertEquals(expectedSQL, actualSQL);
+        assertEquals(Arrays.asList(1, 3), paramMap.get("user message"));
+        assertEquals(Arrays.asList(2), paramMap.get("user"));
+    }
+
+    @Test
+    public void testParseNamedStatementPrefersLongestKnownName() {
+        // Both known names start at the same offset; the longest one that is 
not followed by
+        // another name-class character must win.
+        String sql = "SELECT :MY COL \"MY COL\", :MY COLUMN \"MY COLUMN\" FROM 
t";
+        String[] fieldNames = new String[] {"MY COL", "MY COLUMN"};
+
+        Map<String, List<Integer>> paramMap = new HashMap<>();
+        String actualSQL =
+                FieldNamedPreparedStatement.parseNamedStatement(sql, paramMap, 
fieldNames);
+
+        assertEquals("SELECT ? \"MY COL\", ? \"MY COLUMN\" FROM t", actualSQL);
+        assertEquals(Arrays.asList(1), paramMap.get("MY COL"));
+        assertEquals(Arrays.asList(2), paramMap.get("MY COLUMN"));
+    }
+
+    @Test
+    public void testPrepareStatementWithSpacesInColumnNames() throws Exception 
{
+        String sql = "INSERT INTO test (first_name, last_name) VALUES (:first 
name, :last name)";
+        String[] fieldNames = new String[] {"first name", "last name"};
+        String expectedSQL = "INSERT INTO test (first_name, last_name) VALUES 
(?, ?)";
+
+        Connection connection = mock(Connection.class);
+        PreparedStatement statement = mock(PreparedStatement.class);
+        when(connection.prepareStatement(expectedSQL)).thenReturn(statement);
+
+        FieldNamedPreparedStatement namedStatement =
+                FieldNamedPreparedStatement.prepareStatement(connection, sql, 
fieldNames);
+
+        verify(connection).prepareStatement(expectedSQL);
+        namedStatement.setString(1, "John");
+        namedStatement.setString(2, "Doe");
+        verify(statement).setString(1, "John");
+        verify(statement).setString(2, "Doe");
+    }
 }

Reply via email to