Skip to content

Commit 4655aa9

Browse files
authored
Fix record/replay support for cursor.stats in Snowflake adapter (#1572)
1 parent 0462dbf commit 4655aa9

4 files changed

Lines changed: 236 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
kind: Fixes
2+
body: Fix record/replay support for cursor.stats attribute access.
3+
time: 2026-01-23T15:32:53.925706-05:00
4+
custom:
5+
Author: emmyoop
6+
Issue: "1573"

dbt-snowflake/src/dbt/adapters/snowflake/record/cursor/cursor.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
from dbt.adapters.record import RecordReplayCursor
44
from dbt.adapters.snowflake.record.cursor.sfqid import CursorGetSfqidRecord
55
from dbt.adapters.snowflake.record.cursor.sqlstate import CursorGetSqlStateRecord
6+
from dbt.adapters.snowflake.record.cursor.stats import CursorGetStatsRecord
67

78

89
class SnowflakeRecordReplayCursor(RecordReplayCursor):
9-
"""A custom extension of RecordReplayCursor that adds the sqlstate
10-
and sfqid properties which are specific to snowflake-connector."""
10+
"""A custom extension of RecordReplayCursor that adds the sqlstate,
11+
sfqid, and stats properties which are specific to snowflake-connector."""
1112

1213
@property
1314
@record_function(CursorGetSqlStateRecord, method=True, id_field_name="connection_name")
@@ -18,3 +19,8 @@ def sqlstate(self):
1819
@record_function(CursorGetSfqidRecord, method=True, id_field_name="connection_name")
1920
def sfqid(self):
2021
return self.native_cursor.sfqid
22+
23+
@property
24+
@record_function(CursorGetStatsRecord, method=True, id_field_name="connection_name")
25+
def stats(self):
26+
return self.native_cursor.stats
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import dataclasses
2+
from typing import Any, Optional
3+
4+
from dbt_common.record import Record, Recorder
5+
6+
7+
@dataclasses.dataclass
8+
class CursorGetStatsParams:
9+
connection_name: str
10+
11+
12+
@dataclasses.dataclass
13+
class CursorGetStatsResult:
14+
"""Captures the stats from SnowflakeCursor.stats (QueryResultStats).
15+
16+
These stats are available in snowflake-connector-python >= 4.2.0 and provide
17+
detailed DML operation information.
18+
"""
19+
20+
num_rows_inserted: Optional[int]
21+
num_rows_deleted: Optional[int]
22+
num_rows_updated: Optional[int]
23+
num_dml_duplicates: Optional[int]
24+
25+
26+
class StatsProxy:
27+
"""A proxy object that mimics the QueryResultStats interface for replay mode."""
28+
29+
def __init__(self, result: Optional[CursorGetStatsResult]) -> None:
30+
self._result = result
31+
32+
@property
33+
def num_rows_inserted(self) -> Optional[int]:
34+
return self._result.num_rows_inserted if self._result else None
35+
36+
@property
37+
def num_rows_deleted(self) -> Optional[int]:
38+
return self._result.num_rows_deleted if self._result else None
39+
40+
@property
41+
def num_rows_updated(self) -> Optional[int]:
42+
return self._result.num_rows_updated if self._result else None
43+
44+
@property
45+
def num_dml_duplicates(self) -> Optional[int]:
46+
return self._result.num_dml_duplicates if self._result else None
47+
48+
49+
@Recorder.register_record_type
50+
class CursorGetStatsRecord(Record):
51+
params_cls = CursorGetStatsParams
52+
result_cls = CursorGetStatsResult
53+
group = "Database"
54+
55+
@classmethod
56+
def _record_result(cls, result: Any) -> Optional[CursorGetStatsResult]:
57+
"""Convert the native stats object to our result dataclass."""
58+
if result is None:
59+
return None
60+
return CursorGetStatsResult(
61+
num_rows_inserted=getattr(result, "num_rows_inserted", None),
62+
num_rows_deleted=getattr(result, "num_rows_deleted", None),
63+
num_rows_updated=getattr(result, "num_rows_updated", None),
64+
num_dml_duplicates=getattr(result, "num_dml_duplicates", None),
65+
)
66+
67+
@classmethod
68+
def _replay_result(cls, result: Optional[CursorGetStatsResult]) -> Optional[StatsProxy]:
69+
"""Convert our result dataclass back to a stats-like object for replay."""
70+
if result is None:
71+
return None
72+
return StatsProxy(result)
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from dbt.adapters.snowflake.connections import SnowflakeConnectionManager
2+
from dbt.adapters.snowflake.record.cursor.cursor import SnowflakeRecordReplayCursor
3+
4+
5+
class MockStats:
6+
"""Mock object that mimics snowflake-connector-python's QueryResultStats."""
7+
8+
def __init__(
9+
self,
10+
num_rows_inserted=100,
11+
num_rows_deleted=10,
12+
num_rows_updated=5,
13+
num_dml_duplicates=2,
14+
):
15+
self.num_rows_inserted = num_rows_inserted
16+
self.num_rows_deleted = num_rows_deleted
17+
self.num_rows_updated = num_rows_updated
18+
self.num_dml_duplicates = num_dml_duplicates
19+
20+
21+
class MockCursor:
22+
"""Mock cursor that mimics snowflake-connector-python's SnowflakeCursor."""
23+
24+
def __init__(self, stats=None):
25+
self._stats = stats
26+
27+
@property
28+
def rowcount(self) -> int:
29+
return 42
30+
31+
@property
32+
def sqlstate(self) -> str:
33+
return "00000"
34+
35+
@property
36+
def sfqid(self) -> str:
37+
return "01abc123-0001-abcd-0000-00012345abcd"
38+
39+
@property
40+
def stats(self):
41+
return self._stats
42+
43+
def execute(self, operation, parameters=None) -> None:
44+
pass
45+
46+
@property
47+
def unexpected_prop(self) -> bool:
48+
return True
49+
50+
def unexpected_func(self) -> int:
51+
return 1
52+
53+
54+
class MockConnection:
55+
name = "test_connection"
56+
57+
58+
def test_snowflake_record_cursor_sqlstate():
59+
"""Test that the sqlstate property works correctly."""
60+
recorded_cursor = SnowflakeRecordReplayCursor(MockCursor(), MockConnection()) # type: ignore
61+
assert recorded_cursor.sqlstate == "00000"
62+
63+
64+
def test_snowflake_record_cursor_sfqid():
65+
"""Test that the sfqid property works correctly."""
66+
recorded_cursor = SnowflakeRecordReplayCursor(MockCursor(), MockConnection()) # type: ignore
67+
assert recorded_cursor.sfqid == "01abc123-0001-abcd-0000-00012345abcd"
68+
69+
70+
def test_snowflake_record_cursor_stats():
71+
"""Test that the stats property works correctly."""
72+
mock_stats = MockStats()
73+
recorded_cursor = SnowflakeRecordReplayCursor(
74+
MockCursor(stats=mock_stats), MockConnection()
75+
) # type: ignore
76+
77+
stats = recorded_cursor.stats
78+
assert stats.num_rows_inserted == 100
79+
assert stats.num_rows_deleted == 10
80+
assert stats.num_rows_updated == 5
81+
assert stats.num_dml_duplicates == 2
82+
83+
84+
def test_snowflake_record_cursor_stats_none():
85+
"""Test that the stats property handles None correctly."""
86+
recorded_cursor = SnowflakeRecordReplayCursor(
87+
MockCursor(stats=None), MockConnection()
88+
) # type: ignore
89+
90+
assert recorded_cursor.stats is None
91+
92+
93+
def test_snowflake_record_cursor_inherited_properties():
94+
"""Test that inherited properties from RecordReplayCursor work correctly."""
95+
recorded_cursor = SnowflakeRecordReplayCursor(MockCursor(), MockConnection()) # type: ignore
96+
97+
# Test inherited rowcount property
98+
assert recorded_cursor.rowcount == 42
99+
100+
# Test inherited execute method
101+
recorded_cursor.execute("SELECT 1")
102+
103+
104+
def test_snowflake_record_cursor_unexpected_access():
105+
"""Test that unexpected property/method access fires a warning but still works."""
106+
recorded_cursor = SnowflakeRecordReplayCursor(MockCursor(), MockConnection()) # type: ignore
107+
108+
events = []
109+
# Mock event firing
110+
recorded_cursor._fire_event = events.append
111+
112+
# Test that an unexpected property works, but fires a warning
113+
assert recorded_cursor.unexpected_prop is True
114+
assert len(events) == 1
115+
assert events[0].__class__.__name__ == "RecordReplayIssue"
116+
assert "unexpected_prop" in events[0].msg
117+
events.clear()
118+
119+
# Test that an unexpected function works, but fires a warning
120+
assert recorded_cursor.unexpected_func() == 1
121+
assert len(events) == 1
122+
assert events[0].__class__.__name__ == "RecordReplayIssue"
123+
assert "unexpected_func" in events[0].msg
124+
125+
126+
def test_get_response_no_unexpected_access_warnings():
127+
"""Ensure get_response() doesn't trigger any unexpected attribute access warnings.
128+
129+
This is a regression test. If new cursor attributes are accessed in get_response()
130+
without being added to SnowflakeRecordReplayCursor, this test will fail.
131+
"""
132+
events = []
133+
134+
# Test with stats present
135+
mock_cursor = MockCursor(stats=MockStats())
136+
recorded_cursor = SnowflakeRecordReplayCursor(mock_cursor, MockConnection()) # type: ignore
137+
recorded_cursor._fire_event = events.append
138+
139+
# Call get_response - this is the actual code path
140+
response = SnowflakeConnectionManager.get_response(recorded_cursor)
141+
142+
# Verify no unexpected access warnings were fired
143+
assert len(events) == 0, (
144+
f"Unexpected attribute access in get_response(): {[e.msg for e in events]}. "
145+
"Add the missing attribute(s) to SnowflakeRecordReplayCursor."
146+
)
147+
148+
# Verify the response was created successfully
149+
assert response is not None
150+
assert response.code == "00000" # SQL success state from mock cursor

0 commit comments

Comments
 (0)