Skip to content

Commit 0e09ff7

Browse files
committed
drafted implementation of the SQLUtils and quoting literals and identifiers
1 parent 33a5678 commit 0e09ff7

File tree

4 files changed

+328
-1
lines changed

4 files changed

+328
-1
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.clickhouse.client.api.sql;
2+
3+
import org.testng.annotations.DataProvider;
4+
import org.testng.annotations.Test;
5+
6+
import static org.testng.Assert.assertEquals;
7+
8+
@Test(groups = {"unit"})
9+
public class SQLUtilsTest {
10+
// Test data for enquoteLiteral
11+
@DataProvider(name = "enquoteLiteralTestData")
12+
public Object[][] enquoteLiteralTestData() {
13+
return new Object[][] {
14+
// input, expected output
15+
{"test 123", "'test 123'"},
16+
{"こんにちは世界", "'こんにちは世界'"},
17+
{"O'Reilly", "'O''Reilly'"},
18+
{"😊👍", "'😊👍'"},
19+
{"", "''"},
20+
{"single'quote'double''quote\"", "'single''quote''doubl''e''"}
21+
};
22+
}
23+
24+
// Test data for enquoteIdentifier
25+
@DataProvider(name = "enquoteIdentifierTestData")
26+
public Object[][] enquoteIdentifierTestData() {
27+
return new Object[][] {
28+
// input, expected output
29+
{"column1", "\"column1\""},
30+
{"table.name", "\"table.name\""},
31+
{"column with spaces", "\"column with spaces\""},
32+
{"column\"with\"quotes", "\"column\"\"with\"\"quotes\""},
33+
{"UPPERCASE", "\"UPPERCASE\""},
34+
{"1column", "\"1column\""},
35+
{"column-with-hyphen", "\"column-with-hyphen\""},
36+
{"😊👍", "\"😊👍\""},
37+
{"", "\"\""}
38+
};
39+
}
40+
41+
@Test(dataProvider = "enquoteLiteralTestData")
42+
public void testEnquoteLiteral(String input, String expected) {
43+
assertEquals(SQLUtils.enquoteLiteral(input), expected);
44+
}
45+
46+
@Test(expectedExceptions = IllegalArgumentException.class)
47+
public void testEnquoteLiteral_NullInput() {
48+
SQLUtils.enquoteLiteral(null);
49+
}
50+
51+
@Test(dataProvider = "enquoteIdentifierTestData")
52+
public void testEnquoteIdentifier(String input, String expected) {
53+
// Test with quotesRequired = true (always quote)
54+
assertEquals(SQLUtils.enquoteIdentifier(input), expected);
55+
assertEquals(SQLUtils.enquoteIdentifier(input, true), expected);
56+
57+
// Test with quotesRequired = false (quote only if needed)
58+
boolean needsQuoting = !input.matches("[a-zA-Z_][a-zA-Z0-9_]*");
59+
String expectedUnquoted = needsQuoting ? expected : input;
60+
assertEquals(SQLUtils.enquoteIdentifier(input, false), expectedUnquoted);
61+
}
62+
63+
@Test(expectedExceptions = IllegalArgumentException.class)
64+
public void testEnquoteIdentifier_NullInput() {
65+
SQLUtils.enquoteIdentifier(null);
66+
}
67+
68+
@Test(expectedExceptions = IllegalArgumentException.class)
69+
public void testEnquoteIdentifier_NullInput_WithQuotesRequired() {
70+
SQLUtils.enquoteIdentifier(null, true);
71+
}
72+
73+
@Test
74+
public void testEnquoteIdentifier_NoQuotesWhenNotNeeded() {
75+
// These identifiers don't need quoting
76+
String[] simpleIdentifiers = {
77+
"column1", "table_name", "_id", "a1b2c3", "ColumnName"
78+
};
79+
80+
for (String id : simpleIdentifiers) {
81+
// With quotesRequired=false, should return as-is
82+
assertEquals(SQLUtils.enquoteIdentifier(id, false), id);
83+
// With quotesRequired=true, should be quoted
84+
assertEquals(SQLUtils.enquoteIdentifier(id, true), "\"" + id + "\"");
85+
}
86+
}
87+
88+
@DataProvider(name = "simpleIdentifierTestData")
89+
public Object[][] simpleIdentifierTestData() {
90+
return new Object[][] {
91+
// identifier, expected result
92+
{"Hello", true},
93+
{"hello_world", true},
94+
{"Hello123", true},
95+
{"H", true}, // minimum length
96+
{"a".repeat(128), true}, // maximum length
97+
98+
// Test cases from requirements
99+
{"G'Day", false},
100+
{"\"\"Bruce Wayne\"\"", false},
101+
{"GoodDay$", false},
102+
{"Hello\"\"World", false},
103+
{"\"\"Hello\"\"World\"\"", false},
104+
105+
// Additional test cases
106+
{"", false}, // empty string
107+
{"123test", false}, // starts with number
108+
{"_test", false}, // starts with underscore
109+
{"test-name", false}, // contains hyphen
110+
{"test name", false}, // contains space
111+
{"test\"name", false}, // contains quote
112+
{"test.name", false}, // contains dot
113+
{"a".repeat(129), false}, // exceeds max length
114+
{"testName", true},
115+
{"TEST_NAME", true},
116+
{"test123", true},
117+
{"t123", true},
118+
{"t", true}
119+
};
120+
}
121+
122+
@Test(dataProvider = "simpleIdentifierTestData")
123+
public void testIsSimpleIdentifier(String identifier, boolean expected) {
124+
assertEquals(SQLUtils.isSimpleIdentifier(identifier), expected,
125+
String.format("Failed for identifier: %s", identifier));
126+
}
127+
128+
@Test(expectedExceptions = IllegalArgumentException.class)
129+
public void testIsSimpleIdentifier_NullInput() {
130+
SQLUtils.isSimpleIdentifier(null);
131+
}
132+
133+
134+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.clickhouse.client.api.sql;
2+
3+
public class SQLUtils {
4+
/**
5+
* Escapes and quotes a string literal for use in SQL queries.
6+
*
7+
* @param str the string to be quoted, cannot be null
8+
* @return the quoted and escaped string
9+
* @throws IllegalArgumentException if the input string is null
10+
*/
11+
public static String enquoteLiteral(String str) {
12+
if (str == null) {
13+
throw new IllegalArgumentException("Input string cannot be null");
14+
}
15+
return "'" + str.replace("'", "''") + "'";
16+
}
17+
18+
/**
19+
* Escapes and quotes an SQL identifier (e.g., table or column name) by enclosing it in double quotes.
20+
* Any existing double quotes in the identifier are escaped by doubling them.
21+
*
22+
* @param identifier the identifier to be quoted, cannot be null
23+
* @param quotesRequired if false, the identifier will only be quoted if it contains special characters
24+
* @return the quoted and escaped identifier, or the original identifier if quoting is not required
25+
* @throws IllegalArgumentException if the input identifier is null
26+
*/
27+
public static String enquoteIdentifier(String identifier, boolean quotesRequired) {
28+
if (identifier == null) {
29+
throw new IllegalArgumentException("Identifier cannot be null");
30+
}
31+
32+
if (!quotesRequired && !needsQuoting(identifier)) {
33+
return identifier;
34+
}
35+
return "\"" + identifier.replace("\"", "\"\"") + "\"";
36+
}
37+
38+
/**
39+
* Escapes and quotes an SQL identifier, always adding quotes.
40+
*
41+
* @param identifier the identifier to be quoted, cannot be null
42+
* @return the quoted and escaped identifier
43+
* @throws IllegalArgumentException if the input identifier is null
44+
* @see #enquoteIdentifier(String, boolean)
45+
*/
46+
public static String enquoteIdentifier(String identifier) {
47+
return enquoteIdentifier(identifier, true);
48+
}
49+
50+
/**
51+
* Checks if an identifier needs to be quoted.
52+
* An identifier needs quoting if it:
53+
* - Is empty
54+
* - Contains any non-alphanumeric characters except underscore
55+
* - Starts with a digit
56+
* - Is a reserved keyword (not implemented in this basic version)
57+
*
58+
* @param identifier the identifier to check
59+
* @return true if the identifier needs to be quoted, false otherwise
60+
*/
61+
private static boolean needsQuoting(String identifier) {
62+
if (identifier.isEmpty()) {
63+
return true;
64+
}
65+
66+
// Check if first character is a digit
67+
if (Character.isDigit(identifier.charAt(0))) {
68+
return true;
69+
}
70+
71+
// Check all characters are alphanumeric or underscore
72+
for (int i = 0; i < identifier.length(); i++) {
73+
char c = identifier.charAt(i);
74+
if (!(Character.isLetterOrDigit(c) || c == '_')) {
75+
return true;
76+
}
77+
}
78+
79+
return false;
80+
}
81+
82+
/**
83+
* Checks if the given string is a valid simple SQL identifier that doesn't require quoting.
84+
* A simple identifier must:
85+
* <ul>
86+
* <li>Not be null or empty</li>
87+
* <li>Be between 1 and 128 characters in length (inclusive)</li>
88+
* <li>Start with an alphabetic character (a-z, A-Z)</li>
89+
* <li>Contain only alphanumeric characters or underscores</li>
90+
* <li>Not be enclosed in double quotes</li>
91+
* </ul>
92+
*
93+
* @param identifier the identifier to check
94+
* @return true if the identifier is a valid simple SQL identifier, false otherwise
95+
* @throws IllegalArgumentException if the input identifier is null
96+
*/
97+
// Compiled pattern for simple SQL identifiers
98+
private static final java.util.regex.Pattern SIMPLE_IDENTIFIER_PATTERN =
99+
java.util.regex.Pattern.compile("^[a-zA-Z][a-zA-Z0-9_]{0,127}$");
100+
101+
/**
102+
* Checks if the given string is a valid simple SQL identifier using a compiled regex pattern.
103+
* A simple identifier must match the pattern: ^[a-zA-Z][a-zA-Z0-9_]{0,127}$
104+
*
105+
* @param identifier the identifier to check
106+
* @return true if the identifier is a valid simple SQL identifier, false otherwise
107+
* @throws IllegalArgumentException if the input identifier is null
108+
*/
109+
public static boolean isSimpleIdentifier(String identifier) {
110+
if (identifier == null) {
111+
throw new IllegalArgumentException("Identifier cannot be null");
112+
}
113+
return SIMPLE_IDENTIFIER_PATTERN.matcher(identifier).matches();
114+
}
115+
}

jdbc-v2/src/main/java/com/clickhouse/jdbc/StatementImpl.java

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.clickhouse.client.api.metrics.ServerMetrics;
88
import com.clickhouse.client.api.query.QueryResponse;
99
import com.clickhouse.client.api.query.QuerySettings;
10+
import com.clickhouse.client.api.sql.SQLUtils;
1011
import com.clickhouse.jdbc.internal.ExceptionUtils;
1112
import com.clickhouse.jdbc.internal.ParsedStatement;
1213
import org.slf4j.Logger;
@@ -240,7 +241,7 @@ public void setMaxFieldSize(int max) throws SQLException {
240241
@Override
241242
public int getMaxRows() throws SQLException {
242243
ensureOpen();
243-
return (int) getLargeMaxRows(); // skip overflow check.
244+
return (int) getLargeMaxRows(); // skip overflow check.
244245
}
245246

246247
@Override
@@ -425,6 +426,29 @@ public boolean getMoreResults(int current) throws SQLException {
425426
return false;
426427
}
427428

429+
@Override
430+
public String enquoteLiteral(String val) throws SQLException {
431+
return SQLUtils.enquoteLiteral(val);
432+
}
433+
434+
@Override
435+
public String enquoteIdentifier(String identifier, boolean alwaysQuote) throws SQLException {
436+
return SQLUtils.enquoteIdentifier(identifier, alwaysQuote);
437+
}
438+
439+
@Override
440+
public boolean isSimpleIdentifier(String identifier) throws SQLException {
441+
return SQLUtils.isSimpleIdentifier(identifier);
442+
}
443+
444+
@Override
445+
public String enquoteNCharLiteral(String val) throws SQLException {
446+
if (val == null) {
447+
throw new NullPointerException();
448+
}
449+
return "N" + SQLUtils.enquoteLiteral(val);
450+
}
451+
428452
@Override
429453
public ResultSet getGeneratedKeys() throws SQLException {
430454
// TODO: return empty result set or throw exception
@@ -549,4 +573,6 @@ public long executeLargeUpdate(String sql, String[] columnNames) throws SQLExcep
549573
public String getLastQueryId() {
550574
return lastQueryId;
551575
}
576+
577+
552578
}

jdbc-v2/src/test/java/com/clickhouse/jdbc/StatementTest.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import org.testng.Assert;
88
import com.clickhouse.data.ClickHouseVersion;
99
import org.apache.commons.lang3.RandomStringUtils;
10+
import org.testng.annotations.DataProvider;
1011
import org.testng.annotations.Test;
1112

1213
import java.net.Inet4Address;
@@ -729,4 +730,55 @@ public void testDDLStatements() throws Exception {
729730
}
730731
}
731732
}
733+
734+
@Test(groups = {"integration"})
735+
public void testEnquoteLiteral() throws Exception {
736+
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement()) {
737+
String[] literals = {"test literal", "with single '", "with double ''", "with triple '''"};
738+
for (String literal : literals) {
739+
try (ResultSet rs = stmt.executeQuery("SELECT " + stmt.enquoteLiteral(literal))) {
740+
Assert.assertTrue(rs.next());
741+
assertEquals(rs.getString(1), literal);
742+
}
743+
}
744+
}
745+
}
746+
747+
@Test(groups = {"integration"})
748+
public void testEnquoteIdentifier() throws Exception {
749+
try (Connection conn = getJdbcConnection(); Statement stmt = conn.createStatement()) {
750+
Object[][] identifiers = {{"simple_identifier", false}, {"complex identified", true}};
751+
for (Object[] aCase : identifiers) {
752+
stmt.enquoteIdentifier((String)aCase[0], (boolean) aCase[1]);
753+
}
754+
}
755+
}
756+
757+
@DataProvider(name = "ncharLiteralTestData")
758+
public Object[][] ncharLiteralTestData() {
759+
return new Object[][] {
760+
// input, expected output
761+
{"test", "N'test'"},
762+
{"O'Reilly", "N'O''Reilly'"},
763+
{"", "N''"},
764+
{"test\nnew line", "N'test\nnew line'"},
765+
{"unicode: こんにちは", "N'unicode: こんにちは'"},
766+
{"emoji: 😊", "N'emoji: 😊'"},
767+
{"quote: \"", "N'quote: \"'"}
768+
};
769+
}
770+
771+
@Test(dataProvider = "ncharLiteralTestData")
772+
public void testEnquoteNCharLiteral(String input, String expected) throws SQLException {
773+
try (Statement stmt = getJdbcConnection().createStatement()) {
774+
assertEquals(stmt.enquoteNCharLiteral(input), expected);
775+
}
776+
}
777+
778+
@Test(expectedExceptions = IllegalArgumentException.class)
779+
public void testEnquoteNCharLiteral_NullInput() throws SQLException {
780+
try (Statement stmt = getJdbcConnection().createStatement()) {
781+
stmt.enquoteNCharLiteral(null);
782+
}
783+
}
732784
}

0 commit comments

Comments
 (0)