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"); + } }
