Skip to content

Commit b448bd5

Browse files
SNOW-3713887: Quote stage refs in uploadStream/downloadStream for non-ASCII names (#2683)
### TL;DR Fixed `uploadStream`/`downloadStream` failing with SQL compilation errors when stage references contain non-ASCII characters (e.g. Japanese schema names). ### What changed? Stage references in internally generated PUT/GET commands are now wrapped in single quotes when they contain characters that require quoting per Snowflake SQL syntax. Two new static helpers, `needsStageQuoting` and `quoteStageRefIfNeeded`, were added to `SnowflakeConnectionImpl` to determine whether a stage reference needs quoting and to apply it. Safe characters (ASCII alphanumerics and `_$./@~%"-:`) are left unquoted; anything else (spaces, non-ASCII/unicode, etc.) triggers single-quote wrapping with embedded single quotes doubled. Already-quoted references (single-quote or dollar-quote delimited) are returned unchanged. The `downloadStream` path was also refactored to build the stage reference separately before quoting, replacing the previous ad-hoc special-character check that only considered the file name portion and missed non-ASCII characters in the stage/schema name itself. ### How to test? - `StageQuotingTest` — new unit tests covering `needsStageQuoting` and `quoteStageRefIfNeeded` for ASCII refs, non-ASCII refs, spaces, already-quoted refs, embedded single quotes, and null inputs. - `StreamLatestIT.testUploadDownloadStreamWithUnicodeSchemaStage` — new integration test that creates a schema with a Japanese Unicode name, uploads a file via `uploadStream`, downloads it via `downloadStream`, and asserts the content round-trips correctly. ### Why make this change? `uploadStream` and `downloadStream` construct PUT/GET SQL commands internally. When a stage reference included non-ASCII characters (e.g. a Japanese schema name), the generated SQL was syntactically invalid, causing Snowflake to return a SQL compilation error. Wrapping such references in single quotes makes the generated SQL valid regardless of the characters present in the schema or stage name (SNOW-3713887).
1 parent 4b4df3a commit b448bd5

4 files changed

Lines changed: 265 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Fixed flaky `DatabaseMetaDataLatestIT.testUnderscoreInSchemaNamePatternForPrimaryAndForeignKeys[WithPatternSearchDisabled]`: the SHOW-based `getPrimaryKeys`/`getImportedKeys` lookups could transiently return fewer rows than expected right after the table DDL (eventually-consistent PK/FK constraint metadata), so the lookups are now retried until the expected rows become visible. Test-only change with no driver behavior impact (snowflakedb/snowflake-jdbc#2673).
1010
- Fixed flaky `DatabaseMetaDataIT.testGetPrimarykeys` and `SnowflakeDriverIT.testConstraints` (eventually-consistent PK/FK constraint metadata after DDL) by retrying the constraint metadata lookups until visible. Replaced the live, concurrent stress tests `DatabaseMetaDataLatestIT.test[No]PatternSearchAllowedForPrimaryAndForeignKeys` (which timed out on slow CI runners) with deterministic coverage in the new `DatabaseMetadataWiremockLatestIT`, which validates the `getTables`/`getPrimaryKeys`/`getImportedKeys` SHOW-result mapping and the `enablePatternSearch` pattern-vs-literal behavior using stubbed responses (no live account or metadata-propagation dependency). Test-only change with no driver behavior impact (snowflakedb/snowflake-jdbc#2673).
1111
- Fixed the Loader API (`StreamLoader.setVectorColumns`) throwing `Loader$ConnectionError: ... Result set has been closed` when the `getColumns` metadata lookup returned no rows. The result set returned by `getColumns` closes itself once `next()` runs out of matching rows, so the unchecked `rs.next()` followed by `rs.getString(...)` raised "Result set has been closed"; the result is now read only when `rs.next()` returns a row. This intermittently aborted concurrent loads (e.g. `FlatfileReadMultithreadIT`) when the `SHOW COLUMNS` metadata query raced with concurrent DML on the same table (snowflakedb/snowflake-jdbc#2674).
12+
- Fixed `uploadStream`/`downloadStream` failing with SQL compilation errors when the stage reference contains non-ASCII characters (e.g. Japanese schema names). Stage references in internally generated PUT/GET commands are now wrapped in single quotes when they contain characters that require quoting per Snowflake SQL syntax (SNOW-3713887).
1213

1314
- v4.3.1
1415
- Fixed GCS-backed internal stage PUT failing with opaque `invalid_gcs_credentials` in SPCS pods on GCP: the GCS SDK's Application Default Credentials (ADC) probe was reaching out to `metadata.google.internal` which is unreachable inside SPCS; explicit credentials are now always set when a `GCS_ACCESS_TOKEN` is present, suppressing the ADC probe entirely. Also fixed `GCSAccessStrategyAwsSdk` rejecting custom GCS endpoints that lack an `https://` scheme prefix (e.g. bare `storage.me-central2.rep.googleapis.com`), mirroring the existing handling in `GCSDefaultAccessStrategy`. The catch-all in `setupGCSClient` now chains and logs the original exception instead of swallowing it (snowflakedb/snowflake-jdbc#2664).

src/main/java/net/snowflake/client/internal/api/implementation/connection/SnowflakeConnectionImpl.java

Lines changed: 113 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -976,13 +976,7 @@ private void uploadStreamInternal(
976976

977977
SnowflakeStatementImpl stmt = this.createStatement().unwrap(SnowflakeStatementImpl.class);
978978

979-
StringBuilder destStage = new StringBuilder();
980-
981-
// add stage name
982-
if (!(stageName.startsWith("@") || stageName.startsWith("'@") || stageName.startsWith("$$@"))) {
983-
destStage.append("@");
984-
}
985-
destStage.append(stageName);
979+
StringBuilder destStage = new StringBuilder(normalizeStageNameForRef(stageName));
986980

987981
// add dest prefix
988982
if (destPrefix != null) {
@@ -992,15 +986,18 @@ private void uploadStreamInternal(
992986
destStage.append(destPrefix);
993987
}
994988

989+
String destStageStr = destStage.toString();
990+
String quotedDestStage = quoteStageRefIfNeeded(destStageStr);
991+
995992
StringBuilder putCommand = new StringBuilder();
996993
// use a placeholder for source file
997994
putCommand.append("put file:///tmp/placeholder ");
998-
putCommand.append(destStage.toString());
995+
putCommand.append(quotedDestStage);
999996
putCommand.append(" overwrite=true");
1000997

1001998
SFBaseFileTransferAgent transferAgent =
1002999
sfConnectionHandler.getFileTransferAgent(putCommand.toString(), stmt.getSFBaseStatement());
1003-
transferAgent.setDestStagePath(destStage.toString());
1000+
transferAgent.setDestStagePath(destStageStr);
10041001
transferAgent.setSourceStream(inputStream);
10051002
transferAgent.setDestFileNameForStreamSource(destFileName);
10061003
transferAgent.setCompressSourceFromStream(compressData);
@@ -1049,30 +1046,12 @@ public InputStream downloadStream(
10491046
ResultSet.CONCUR_READ_ONLY,
10501047
ResultSet.CLOSE_CURSORS_AT_COMMIT);
10511048

1052-
StringBuilder getCommand = new StringBuilder();
1049+
String stageRef = buildStageFileRef(stageName, sourceFileName);
1050+
String quotedStageRef = quoteStageRefIfNeeded(stageRef);
10531051

1052+
StringBuilder getCommand = new StringBuilder();
10541053
getCommand.append("get ");
1055-
1056-
if (!stageName.startsWith("@")) {
1057-
getCommand.append("@");
1058-
}
1059-
1060-
getCommand.append(stageName);
1061-
1062-
getCommand.append("/");
1063-
1064-
if (sourceFileName.startsWith("/")) {
1065-
sourceFileName = sourceFileName.substring(1);
1066-
}
1067-
1068-
getCommand.append(sourceFileName);
1069-
1070-
// special characters and spaces require single quotes around stage name.
1071-
boolean isSpecialChar = !sourceFileName.matches("^[a-zA-Z0-9_/.]*$");
1072-
if (isSpecialChar) {
1073-
getCommand.insert(getCommand.indexOf("@"), "'");
1074-
getCommand.append("'");
1075-
}
1054+
getCommand.append(quotedStageRef);
10761055

10771056
// this is a fake path, used to form Get query and retrieve stage info,
10781057
// no file will be downloaded to this location
@@ -1174,4 +1153,107 @@ public void removeClosedStatement(Statement stmt) {
11741153
private static String escapeIdentifier(String name) {
11751154
return name == null ? null : name.replace("\"", "\"\"");
11761155
}
1156+
1157+
/**
1158+
* Strips outer single-quote or dollar-quote delimiters from a stage name, unescapes doubled
1159+
* single quotes, and ensures the result starts with {@code @}.
1160+
*/
1161+
static String normalizeStageNameForRef(String stageName) {
1162+
if (stageName == null) {
1163+
return null;
1164+
}
1165+
String normalized = stageName;
1166+
if (isDollarQuotedStageRef(normalized)) {
1167+
normalized = normalized.substring(2, normalized.length() - 2);
1168+
} else if (isSingleQuotedStageRef(normalized)) {
1169+
normalized = unescapeSingleQuotedContent(normalized.substring(1, normalized.length() - 1));
1170+
}
1171+
if (!normalized.startsWith("@")) {
1172+
normalized = "@" + normalized;
1173+
}
1174+
return normalized;
1175+
}
1176+
1177+
/** Builds an unquoted {@code @stage/file} reference for GET commands. */
1178+
static String buildStageFileRef(String stageName, String fileName) {
1179+
StringBuilder ref = new StringBuilder(normalizeStageNameForRef(stageName));
1180+
ref.append("/");
1181+
if (fileName != null && fileName.startsWith("/")) {
1182+
fileName = fileName.substring(1);
1183+
}
1184+
ref.append(fileName);
1185+
return ref.toString();
1186+
}
1187+
1188+
private static String unescapeSingleQuotedContent(String content) {
1189+
return content.replace("''", "'");
1190+
}
1191+
1192+
private static boolean containsUnescapedSingleQuote(String content) {
1193+
for (int i = 0; i < content.length(); i++) {
1194+
if (content.charAt(i) == '\'') {
1195+
if (i + 1 < content.length() && content.charAt(i + 1) == '\'') {
1196+
i++;
1197+
} else {
1198+
return true;
1199+
}
1200+
}
1201+
}
1202+
return false;
1203+
}
1204+
1205+
private static boolean isSingleQuotedStageRef(String stageRef) {
1206+
if (stageRef == null || stageRef.length() < 2) {
1207+
return false;
1208+
}
1209+
if (!stageRef.startsWith("'") || !stageRef.endsWith("'")) {
1210+
return false;
1211+
}
1212+
return !containsUnescapedSingleQuote(stageRef.substring(1, stageRef.length() - 1));
1213+
}
1214+
1215+
private static boolean isDollarQuotedStageRef(String stageRef) {
1216+
return stageRef != null
1217+
&& stageRef.length() >= 4
1218+
&& stageRef.startsWith("$$")
1219+
&& stageRef.endsWith("$$");
1220+
}
1221+
1222+
/**
1223+
* Returns true if a stage reference contains characters that require single-quote wrapping in
1224+
* PUT/GET SQL. Safe characters are ASCII alphanumerics and {@code _$./@~%"-:} which are valid
1225+
* unquoted in Snowflake file-operation SQL. Anything else (spaces, non-ASCII/unicode, backslash,
1226+
* wildcard chars) triggers quoting.
1227+
*/
1228+
static boolean needsStageQuoting(String stageRef) {
1229+
if (stageRef == null) {
1230+
return false;
1231+
}
1232+
if (isSingleQuotedStageRef(stageRef) || isDollarQuotedStageRef(stageRef)) {
1233+
return false;
1234+
}
1235+
for (int i = 0; i < stageRef.length(); i++) {
1236+
char c = stageRef.charAt(i);
1237+
if ((c >= 'A' && c <= 'Z')
1238+
|| (c >= 'a' && c <= 'z')
1239+
|| (c >= '0' && c <= '9')
1240+
|| "_$./@~%\"-:".indexOf(c) >= 0) {
1241+
continue;
1242+
}
1243+
return true;
1244+
}
1245+
return false;
1246+
}
1247+
1248+
/**
1249+
* Wraps a stage reference in single quotes if it contains characters that require quoting (e.g.
1250+
* non-ASCII/unicode characters in schema or stage names). Any embedded single quotes are doubled.
1251+
* Already-quoted references (single-quote or dollar-quote) are returned unchanged.
1252+
*/
1253+
static String quoteStageRefIfNeeded(String stageRef) {
1254+
if (stageRef == null || !needsStageQuoting(stageRef)) {
1255+
return stageRef;
1256+
}
1257+
return "'" + stageRef.replace("'", "''") + "'";
1258+
}
11771259
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package net.snowflake.client.internal.api.implementation.connection;
2+
3+
import static net.snowflake.client.internal.api.implementation.connection.SnowflakeConnectionImpl.buildStageFileRef;
4+
import static net.snowflake.client.internal.api.implementation.connection.SnowflakeConnectionImpl.needsStageQuoting;
5+
import static net.snowflake.client.internal.api.implementation.connection.SnowflakeConnectionImpl.normalizeStageNameForRef;
6+
import static net.snowflake.client.internal.api.implementation.connection.SnowflakeConnectionImpl.quoteStageRefIfNeeded;
7+
import static org.junit.jupiter.api.Assertions.assertEquals;
8+
import static org.junit.jupiter.api.Assertions.assertFalse;
9+
import static org.junit.jupiter.api.Assertions.assertNull;
10+
import static org.junit.jupiter.api.Assertions.assertTrue;
11+
12+
import org.junit.jupiter.api.Test;
13+
14+
/** Unit tests for stage-reference quoting helpers in {@link SnowflakeConnectionImpl}. */
15+
public class StageQuotingTest {
16+
17+
@Test
18+
public void needsStageQuotingReturnsFalseForAsciiRefs() {
19+
assertFalse(needsStageQuoting("@stage"));
20+
assertFalse(needsStageQuoting("@stage/dir/file.csv"));
21+
assertFalse(needsStageQuoting("@~/dir/file.csv"));
22+
assertFalse(needsStageQuoting("@\"DB\".\"SCHEMA\".\"STAGE\""));
23+
assertFalse(needsStageQuoting("@db.schema.%table/dir"));
24+
assertFalse(needsStageQuoting(null));
25+
}
26+
27+
@Test
28+
public void needsStageQuotingReturnsTrueForNonAscii() {
29+
assertTrue(needsStageQuoting("@\"DB\".\"日本語\".STAGE"));
30+
assertTrue(needsStageQuoting("@\"DQ\".\"自動化専用_変更禁止\".SNOWPARK_TEMP_STAGE_XYZ"));
31+
assertTrue(needsStageQuoting("@\"café\".STAGE"));
32+
}
33+
34+
@Test
35+
public void needsStageQuotingReturnsTrueForSpacesAndParens() {
36+
assertTrue(needsStageQuoting("@\"my stage\"/path/file.csv"));
37+
assertTrue(needsStageQuoting("@\"ice cream (nice)\"/hello.txt"));
38+
}
39+
40+
@Test
41+
public void needsStageQuotingReturnsFalseForAlreadyQuoted() {
42+
assertFalse(needsStageQuoting("'@\"DB\".\"日本語\".STAGE'"));
43+
assertFalse(needsStageQuoting("$$@\"DB\".\"日本語\".STAGE$$"));
44+
}
45+
46+
@Test
47+
public void needsStageQuotingReturnsTrueForMalformedQuotedRef() {
48+
assertTrue(needsStageQuoting("'a'b'c'"));
49+
}
50+
51+
@Test
52+
public void normalizeStageNameForRefStripsOuterQuotes() {
53+
assertEquals("@stage", normalizeStageNameForRef("stage"));
54+
assertEquals("@stage", normalizeStageNameForRef("@stage"));
55+
assertEquals("@%\"ice cream (nice)\"", normalizeStageNameForRef("'@%\"ice cream (nice)\"'"));
56+
assertEquals("@%\"ice cream (nice)\"", normalizeStageNameForRef("$$@%\"ice cream (nice)\"$$"));
57+
assertEquals("@stage_O'Brien", normalizeStageNameForRef("'@stage_O''Brien'"));
58+
}
59+
60+
@Test
61+
public void buildStageFileRefHandlesPreQuotedStageName() {
62+
assertEquals("@stage/file.csv", buildStageFileRef("@stage", "file.csv"));
63+
assertEquals("@stage/file.csv", buildStageFileRef("@stage", "/file.csv"));
64+
assertEquals(
65+
"@%\"ice cream (nice)\"/hello.txt",
66+
buildStageFileRef("'@%\"ice cream (nice)\"'", "hello.txt"));
67+
assertEquals(
68+
"'@%\"ice cream (nice)\"/hello.txt'",
69+
quoteStageRefIfNeeded(buildStageFileRef("'@%\"ice cream (nice)\"'", "hello.txt")));
70+
}
71+
72+
@Test
73+
public void quoteStageRefIfNeededQuotesNonAscii() {
74+
assertEquals("'@\"DB\".\"日本語\".STAGE'", quoteStageRefIfNeeded("@\"DB\".\"日本語\".STAGE"));
75+
assertEquals(
76+
"'@\"DQ\".\"自動化専用_変更禁止\".SNOWPARK_TEMP_STAGE_XYZ/file.txt'",
77+
quoteStageRefIfNeeded("@\"DQ\".\"自動化専用_変更禁止\".SNOWPARK_TEMP_STAGE_XYZ/file.txt"));
78+
}
79+
80+
@Test
81+
public void quoteStageRefIfNeededLeavesAsciiUnchanged() {
82+
assertEquals("@stage", quoteStageRefIfNeeded("@stage"));
83+
assertEquals("@stage/dir/file.csv", quoteStageRefIfNeeded("@stage/dir/file.csv"));
84+
assertEquals("@\"DB\".\"SCHEMA\".STAGE", quoteStageRefIfNeeded("@\"DB\".\"SCHEMA\".STAGE"));
85+
}
86+
87+
@Test
88+
public void quoteStageRefIfNeededLeavesAlreadyQuotedUnchanged() {
89+
String singleQuoted = "'@\"DB\".\"日本語\".STAGE'";
90+
assertEquals(singleQuoted, quoteStageRefIfNeeded(singleQuoted));
91+
String dollarQuoted = "$$@\"DB\".\"日本語\".STAGE$$";
92+
assertEquals(dollarQuoted, quoteStageRefIfNeeded(dollarQuoted));
93+
}
94+
95+
@Test
96+
public void quoteStageRefIfNeededEscapesEmbeddedSingleQuotes() {
97+
assertEquals("'@stage_O''Brien'", quoteStageRefIfNeeded("@stage_O'Brien"));
98+
assertEquals("'@\"sch''ema\".STAGE'", quoteStageRefIfNeeded("@\"sch'ema\".STAGE"));
99+
}
100+
101+
@Test
102+
public void quoteStageRefIfNeededHandlesNull() {
103+
assertNull(quoteStageRefIfNeeded(null));
104+
}
105+
}

src/test/java/net/snowflake/client/internal/jdbc/StreamLatestIT.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,50 @@ private static boolean putFile(
359359
"PUT file://%s %s AUTO_COMPRESS=%s",
360360
localFileName, stageDest, String.valueOf(autoCompress).toUpperCase()));
361361
}
362+
363+
/** SNOW-3713887: uploadStream/downloadStream with unicode schema in stage name */
364+
@Test
365+
public void testUploadDownloadStreamWithUnicodeSchemaStage() throws Throwable {
366+
String unicodeSchema = "\"日本語テスト_" + UUID.randomUUID().toString().substring(0, 5) + "\"";
367+
String stageName = "test_stream_stage_" + UUID.randomUUID().toString().replaceAll("-", "");
368+
369+
try (Connection connection = getConnection();
370+
Statement statement = connection.createStatement()) {
371+
String database = connection.getCatalog();
372+
String qualifiedStage = database + "." + unicodeSchema + "." + stageName;
373+
374+
try {
375+
statement.execute("CREATE SCHEMA IF NOT EXISTS " + database + "." + unicodeSchema);
376+
statement.execute("CREATE TEMPORARY STAGE " + qualifiedStage);
377+
378+
String content = "hello from unicode schema";
379+
String fileName = "unicode_test.txt";
380+
381+
connection
382+
.unwrap(SnowflakeConnection.class)
383+
.uploadStream(
384+
"@" + qualifiedStage,
385+
fileName,
386+
new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)),
387+
UploadStreamConfig.builder().setCompressData(false).build());
388+
389+
try (InputStream in =
390+
connection
391+
.unwrap(SnowflakeConnection.class)
392+
.downloadStream(
393+
"@" + qualifiedStage,
394+
"/" + fileName,
395+
DownloadStreamConfig.builder().setDecompress(false).build())) {
396+
String downloaded =
397+
new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))
398+
.lines()
399+
.collect(Collectors.joining("\n"));
400+
assertEquals(content, downloaded);
401+
}
402+
} finally {
403+
statement.execute("DROP STAGE IF EXISTS " + qualifiedStage);
404+
statement.execute("DROP SCHEMA IF EXISTS " + database + "." + unicodeSchema);
405+
}
406+
}
407+
}
362408
}

0 commit comments

Comments
 (0)