Skip to content

Commit 0d6bc48

Browse files
sfc-gh-turbaszekclaudesfc-gh-pczajka
authored
SNOW-3665226: prevent fully-qualified DDL from polluting schema/database cache (#2909)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Patryk Czajka <patryk.czajka@snowflake.com>
1 parent 449a6b3 commit 0d6bc48

4 files changed

Lines changed: 136 additions & 4 deletions

File tree

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1818
- Fixed S3 storage client to correctly handle 307/308 (method-preserving) and 301/302 (GET/HEAD only) redirects by disabling automatic redirect following and re-signing each request with AWS SigV4 credentials for the redirect target. The region is updated from the `x-amz-bucket-region` response header on each redirect. Redirects are capped at 5 hops.
1919
- Added native AKS (Azure Kubernetes Service) workload identity support. When running on AKS with workload identity configured, the connector automatically uses `WorkloadIdentityCredential` to authenticate via the injected service account credentials. OIDC backward compatibility is also supported.
2020
- Added the `workload_identity_aws_use_outbound_token` connection option (default `false`) to opt into AWS WIF JWT attestation via STS `GetWebIdentityToken` instead of the default SigV4 `GetCallerIdentity` method.
21+
- Fixed a bug where a fully-qualified DDL statement (e.g. `CREATE VIEW db.schema.obj`) on a session with no current schema would populate the connector's cached `_schema`/`_database` from the referenced object's namespace. This made `get_current_schema()` diverge from the server's `CURRENT_SCHEMA()` and mis-qualified Snowpark temp objects (SNOW-3665226).
2122

2223
- v4.6.0(May 28,2026)
2324
- Dropped support for Python 3.9. The minimum supported version is now Python 3.10.

src/snowflake/connector/aio/_connection.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,9 +1084,14 @@ async def cmd_query(
10841084
ret["data"] = {}
10851085
if _update_current_object:
10861086
data = ret["data"]
1087-
if "finalDatabaseName" in data and data["finalDatabaseName"] is not None:
1087+
is_context_switch = sql.strip().upper().startswith("USE ")
1088+
if data.get("finalDatabaseName") is not None and (
1089+
self._database is not None or is_context_switch
1090+
):
10881091
self._database = data["finalDatabaseName"]
1089-
if "finalSchemaName" in data and data["finalSchemaName"] is not None:
1092+
if data.get("finalSchemaName") is not None and (
1093+
self._schema is not None or is_context_switch
1094+
):
10901095
self._schema = data["finalSchemaName"]
10911096
if "finalWarehouseName" in data and data["finalWarehouseName"] is not None:
10921097
self._warehouse = data["finalWarehouseName"]

src/snowflake/connector/connection.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1989,9 +1989,14 @@ def cmd_query(
19891989
ret["data"] = {}
19901990
if _update_current_object:
19911991
data = ret["data"]
1992-
if "finalDatabaseName" in data and data["finalDatabaseName"] is not None:
1992+
is_context_switch = sql.strip().upper().startswith("USE ")
1993+
if data.get("finalDatabaseName") is not None and (
1994+
self._database is not None or is_context_switch
1995+
):
19931996
self._database = data["finalDatabaseName"]
1994-
if "finalSchemaName" in data and data["finalSchemaName"] is not None:
1997+
if data.get("finalSchemaName") is not None and (
1998+
self._schema is not None or is_context_switch
1999+
):
19952000
self._schema = data["finalSchemaName"]
19962001
if "finalWarehouseName" in data and data["finalWarehouseName"] is not None:
19972002
self._warehouse = data["finalWarehouseName"]

test/integ/test_connection.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2685,3 +2685,124 @@ def test_empty_result_stats(conn_cnx):
26852685
num_dml_duplicates=None,
26862686
),
26872687
)
2688+
2689+
2690+
def test_fqn_ddl_does_not_pollute_schema_cache(conn_cnx, db_parameters):
2691+
"""SNOW-3665226: a fully-qualified DDL must not populate the connector's
2692+
cached _schema/_database when the session has none set (finalSchemaName/
2693+
finalDatabaseName reflect the referenced object, not the session context).
2694+
"""
2695+
database = db_parameters["database"].upper()
2696+
schema = db_parameters["schema"].upper()
2697+
view_name = f'"{database}"."{schema}"."SNOW_3665226_REPRO_VIEW"'
2698+
2699+
# Open a session with NO database and NO schema so both caches start as None.
2700+
# The FQN object below lives in `database`.`schema`; without the guard the
2701+
# connector would overwrite the None caches with the object's namespace. We
2702+
# start _database as None too (not just _schema) so its "None is not
2703+
# overridden" path is actually exercised rather than a tautology.
2704+
with conn_cnx(database=None, schema=None) as cnx:
2705+
with cnx.cursor() as cur:
2706+
# Sanity-check: cache and server agree (both empty) before the test.
2707+
assert cnx._database is None
2708+
assert cnx._schema is None
2709+
server_db_before, server_schema_before = cur.execute(
2710+
"SELECT CURRENT_DATABASE(), CURRENT_SCHEMA()"
2711+
).fetchone()
2712+
assert server_db_before is None
2713+
assert server_schema_before is None
2714+
2715+
# Touch a fully-qualified object — this must not change the session context.
2716+
try:
2717+
cur.execute(f"CREATE OR REPLACE TEMP VIEW {view_name} AS SELECT 1 AS x")
2718+
finally:
2719+
cur.execute(f"DROP VIEW IF EXISTS {view_name}")
2720+
2721+
# Caches must still be None — the session context never changed.
2722+
assert cnx._database is None, (
2723+
f"connector cache was polluted: _database={cnx._database!r}, "
2724+
"but no USE DATABASE was issued"
2725+
)
2726+
assert cnx._schema is None, (
2727+
f"connector cache was polluted: _schema={cnx._schema!r}, "
2728+
"but no USE SCHEMA was issued"
2729+
)
2730+
2731+
# Server must also still return NULL for the current namespace.
2732+
server_db_after, server_schema_after = cur.execute(
2733+
"SELECT CURRENT_DATABASE(), CURRENT_SCHEMA()"
2734+
).fetchone()
2735+
assert (
2736+
server_db_after is None
2737+
), f"unexpected server database: {server_db_after!r}"
2738+
assert (
2739+
server_schema_after is None
2740+
), f"unexpected server schema: {server_schema_after!r}"
2741+
2742+
2743+
def test_fqn_ddl_does_not_pollute_schema_cache_with_active_context(
2744+
conn_cnx, db_parameters
2745+
):
2746+
"""SNOW-3665226: fully-qualified DDL must not mutate the connector's cached
2747+
current schema / database when the session *does* have an active schema set.
2748+
2749+
Repro variant: connect with an explicit database+schema context, then execute
2750+
a DDL referencing a fully-qualified object in a *different* schema. Before
2751+
the fix the connector would overwrite _schema/_database with the object's
2752+
schema, causing get_current_schema() to diverge from CURRENT_SCHEMA().
2753+
"""
2754+
unique = random_string(5).lower()
2755+
database = db_parameters["database"].upper()
2756+
session_schema = f"SNOW_3665226_SESSION_{unique}".upper()
2757+
other_schema = f"SNOW_3665226_OTHER_{unique}".upper()
2758+
2759+
# Use a separate bootstrap connection to create two schemas in the existing
2760+
# test database (avoids needing CREATE DATABASE privilege).
2761+
with conn_cnx() as bootstrap_cnx:
2762+
with bootstrap_cnx.cursor() as cur:
2763+
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {session_schema}")
2764+
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {other_schema}")
2765+
2766+
try:
2767+
# Connect with the known db+schema as the session context.
2768+
with conn_cnx(database=database, schema=session_schema) as cnx:
2769+
with cnx.cursor() as cur:
2770+
# Sanity-check: cache and server agree before the DDL.
2771+
assert cnx._database.upper() == database
2772+
assert cnx._schema.upper() == session_schema
2773+
server_schema_before = cur.execute(
2774+
"SELECT CURRENT_SCHEMA()"
2775+
).fetchone()[0]
2776+
assert server_schema_before.upper() == session_schema
2777+
2778+
# Execute DDL targeting a *different* schema (fully-qualified).
2779+
view_fqn = f'"{database}"."{other_schema}"."SNOW_3665226_VIEW_{unique}"'
2780+
try:
2781+
cur.execute(
2782+
f"CREATE OR REPLACE TEMP VIEW {view_fqn} AS SELECT 1 AS x"
2783+
)
2784+
finally:
2785+
cur.execute(f"DROP VIEW IF EXISTS {view_fqn}")
2786+
2787+
# The connector cache must still reflect the original session context.
2788+
assert cnx._schema.upper() == session_schema, (
2789+
f"connector _schema was polluted: got {cnx._schema!r}, "
2790+
f"expected {session_schema!r}"
2791+
)
2792+
assert cnx._database.upper() == database, (
2793+
f"connector _database was polluted: got {cnx._database!r}, "
2794+
f"expected {database!r}"
2795+
)
2796+
2797+
# Server must also still report the original schema.
2798+
server_schema_after = cur.execute("SELECT CURRENT_SCHEMA()").fetchone()[
2799+
0
2800+
]
2801+
assert (
2802+
server_schema_after.upper() == session_schema
2803+
), f"unexpected server schema: {server_schema_after!r}"
2804+
finally:
2805+
with conn_cnx() as cleanup_cnx:
2806+
with cleanup_cnx.cursor() as cur:
2807+
cur.execute(f"DROP SCHEMA IF EXISTS {session_schema}")
2808+
cur.execute(f"DROP SCHEMA IF EXISTS {other_schema}")

0 commit comments

Comments
 (0)