Skip to content

Commit de8850a

Browse files
committed
feat(sqlserver): add statement-level NOSCAN execution option
1 parent d46f46c commit de8850a

6 files changed

Lines changed: 293 additions & 4 deletions

File tree

‎src/rhosocial/activerecord/backend/impl/sqlserver/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .config import SQLServerConnectionConfig
66
from .collation import SQLServerCollation
77
from .dialect import SQLServerDialect
8+
from .options import SQLServerExecutionOptions
89
from .transaction import SQLServerTransactionManager
910
from .adapters import (
1011
SQLServerUUIDAdapter,
@@ -66,6 +67,7 @@
6667
__all__ = [
6768
"SQLServerBackend",
6869
"SQLServerConnectionConfig",
70+
"SQLServerExecutionOptions",
6971
"SQLServerDialect",
7072
"SQLServerCollation",
7173
"SQLServerTransactionManager",

‎src/rhosocial/activerecord/backend/impl/sqlserver/async_backend.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import asyncio
1010
import logging
1111
import time
12-
from typing import Any, Dict, List, Optional, Set, Tuple
12+
from contextlib import asynccontextmanager
13+
from typing import Any, AsyncIterator, Dict, List, Optional, Set, Tuple
1314

1415
from rhosocial.activerecord.backend.base import AsyncStorageBackend
1516
from rhosocial.activerecord.backend.base.operations import AsyncSQLOperationsMixin
@@ -329,6 +330,21 @@ async def execute(
329330

330331
return await self._execute_statement(sql, params, options)
331332

333+
@asynccontextmanager
334+
async def _cursor_noscan(self, cursor: Any, noscan: Optional[bool]) -> AsyncIterator[None]:
335+
if noscan is None:
336+
yield
337+
return
338+
339+
impl = cursor._impl
340+
run_operation = cursor._run_operation
341+
previous = await run_operation(getattr, impl, "noscan")
342+
try:
343+
await run_operation(setattr, impl, "noscan", noscan)
344+
yield
345+
finally:
346+
await run_operation(setattr, impl, "noscan", previous)
347+
332348
async def _execute_statement(
333349
self, sql: str, params: Optional[Tuple], options, start_time: Optional[float] = None
334350
) -> QueryResult:
@@ -366,7 +382,8 @@ async def _execute_statement(
366382
if identity_table is not None:
367383
await self.set_identity_insert(identity_table, True)
368384
try:
369-
await cursor.execute(final_sql, final_params or ())
385+
async with self._cursor_noscan(cursor, getattr(options, "noscan", None)):
386+
await cursor.execute(final_sql, final_params or ())
370387
finally:
371388
if identity_table is not None:
372389
await self.set_identity_insert(identity_table, False)

‎src/rhosocial/activerecord/backend/impl/sqlserver/backend.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import logging
1111
import re
1212
import time
13+
from contextvars import ContextVar
1314
from typing import Any, Dict, List, Optional, Tuple, Type
1415

1516
import pyodbc
@@ -24,6 +25,7 @@
2425
OperationalError,
2526
QueryError,
2627
)
28+
from rhosocial.activerecord.backend.options import ExecutionOptions
2729
from rhosocial.activerecord.backend.result import QueryResult
2830
from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin
2931
from rhosocial.activerecord.backend.explain import SyncExplainBackendMixin
@@ -161,6 +163,9 @@ def __init__(self, **kwargs):
161163
self._connection = None
162164
self._transaction_manager = None
163165
self._default_suggestions_cache = None
166+
self._execution_noscan: ContextVar[Optional[bool]] = ContextVar(
167+
"sqlserver_execution_noscan", default=None
168+
)
164169

165170
self._register_sqlserver_adapters()
166171

@@ -358,7 +363,7 @@ def execute(
358363
cursor.execute(f"SET {set_clause} ON")
359364
cursor.close()
360365
try:
361-
return super().execute(statement, params, options=options)
366+
return self._execute_with_options(statement, params, options)
362367
finally:
363368
cursor = self._get_cursor()
364369
cursor.execute(f"SET {set_clause} OFF")
@@ -368,7 +373,7 @@ def execute(
368373
if identity_table is not None:
369374
self.set_identity_insert(identity_table, True)
370375
try:
371-
result = super().execute(sql, params, options=options)
376+
result = self._execute_with_options(sql, params, options)
372377
finally:
373378
if identity_table is not None:
374379
self.set_identity_insert(identity_table, False)
@@ -380,6 +385,27 @@ def execute(
380385

381386
return result
382387

388+
def _execute_with_options(
389+
self, sql: str, params: Optional[Tuple], options: ExecutionOptions
390+
) -> QueryResult:
391+
token = self._execution_noscan.set(getattr(options, "noscan", None))
392+
try:
393+
return super().execute(sql, params, options=options)
394+
finally:
395+
self._execution_noscan.reset(token)
396+
397+
def _execute_query(self, cursor: Any, sql: str, params: Optional[Tuple]) -> Any:
398+
noscan = self._execution_noscan.get()
399+
if noscan is None:
400+
return super()._execute_query(cursor, sql, params)
401+
402+
previous = cursor.noscan
403+
try:
404+
cursor.noscan = noscan
405+
return super()._execute_query(cursor, sql, params)
406+
finally:
407+
cursor.noscan = previous
408+
383409
def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult:
384410
"""Execute the same SQL statement multiple times with different parameters.
385411
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from dataclasses import dataclass
2+
from typing import Optional
3+
4+
from rhosocial.activerecord.backend.options import ExecutionOptions
5+
6+
7+
@dataclass
8+
class SQLServerExecutionOptions(ExecutionOptions):
9+
noscan: Optional[bool] = None

‎tests/rhosocial/activerecord_sqlserver_test/feature/backend/dialect/test_sqlserver_sql_graph.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@
3434
SQLServerEdgeConstraint,
3535
SQLServerGraphTableKind,
3636
)
37+
from rhosocial.activerecord.backend.impl.sqlserver.options import SQLServerExecutionOptions
3738
from rhosocial.activerecord.backend.impl.sqlserver.protocols import SQLServerGraphSupport
3839
from rhosocial.activerecord.testsuite.utils import requires_protocol, skip_test_if_protocol_unsupported
40+
from rhosocial.activerecord.backend.options import StatementType
3941

4042

4143
@pytest.fixture
@@ -419,6 +421,7 @@ def test_shortest_path_and_edge_constraint(self, sqlserver_backend, maximum, dir
419421
f"SELECT LAST_VALUE([Person{segment_count + 1}].ID) WITHIN GROUP (GRAPH PATH) AS [endpoint_id] "
420422
f"FROM {', '.join(tables)} WHERE {match_sql} AND [Person1].ID = {dialect.p()}",
421423
params + (start_id,),
424+
options=SQLServerExecutionOptions(stmt_type=StatementType.DQL, noscan=True),
422425
)
423426
assert result.data is not None
424427
actual = {row["endpoint_id"] for row in result.data}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
from types import SimpleNamespace
2+
from unittest.mock import AsyncMock, MagicMock
3+
4+
import pytest
5+
6+
from rhosocial.activerecord.backend.base import StorageBackend
7+
from rhosocial.activerecord.backend.errors import DatabaseError
8+
from rhosocial.activerecord.backend.impl.sqlserver import (
9+
AsyncSQLServerBackend,
10+
SQLServerBackend,
11+
SQLServerExecutionOptions,
12+
)
13+
from rhosocial.activerecord.backend.impl.sqlserver.config import SQLServerConnectionConfig
14+
from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType
15+
16+
17+
SQL = "SELECT {fn UCASE(?)} AS [value], '?' AS [literal]"
18+
PARAMS = ("{not an ODBC escape}",)
19+
OPTION_CASES = [
20+
pytest.param(SQLServerExecutionOptions(StatementType.DQL, noscan=True), id="true"),
21+
pytest.param(SQLServerExecutionOptions(StatementType.DQL, noscan=False), id="false"),
22+
pytest.param(SQLServerExecutionOptions(StatementType.DQL, noscan=None), id="none"),
23+
pytest.param(ExecutionOptions(StatementType.DQL), id="core"),
24+
]
25+
26+
27+
@pytest.fixture(scope="session", autouse=True)
28+
def _prepare_read_committed_snapshot():
29+
return None
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def block_connections(monkeypatch):
34+
monkeypatch.setattr(
35+
SQLServerBackend, "connect", MagicMock(side_effect=AssertionError("Unexpected database connection"))
36+
)
37+
monkeypatch.setattr(
38+
AsyncSQLServerBackend, "connect", AsyncMock(side_effect=AssertionError("Unexpected database connection"))
39+
)
40+
41+
42+
class NativeCursor:
43+
def __init__(self, noscan):
44+
self._noscan = noscan
45+
self.accesses = []
46+
47+
@property
48+
def noscan(self):
49+
self.accesses.append(("get",))
50+
return self._noscan
51+
52+
@noscan.setter
53+
def noscan(self, value):
54+
self.accesses.append(("set", value))
55+
self._noscan = value
56+
57+
58+
class AsyncCursor:
59+
def __init__(self, previous, error=None):
60+
self._impl = NativeCursor(previous)
61+
self.noscan = "wrapper must not change"
62+
self.operations = []
63+
self.executions = []
64+
self.error = error
65+
self.description = None
66+
self.rowcount = 0
67+
self.lastrowid = None
68+
self.exit_value = None
69+
70+
async def _run_operation(self, operation, *args):
71+
self.operations.append((operation, args))
72+
return operation(*args)
73+
74+
async def __aenter__(self):
75+
return self
76+
77+
async def __aexit__(self, exc_type, exc_value, traceback):
78+
self.exit_value = self._impl._noscan
79+
80+
async def execute(self, sql, params):
81+
self.executions.append((sql, params, self._impl._noscan, self.noscan))
82+
if self.error is not None:
83+
raise self.error
84+
85+
86+
def make_backend(backend_class):
87+
config = SQLServerConnectionConfig(host="localhost", database="master", username="sa", password="")
88+
backend = backend_class(connection_config=config)
89+
backend._connection = object()
90+
return backend
91+
92+
93+
def expected_accesses(noscan, previous):
94+
return [] if noscan is None else [("get",), ("set", noscan), ("set", previous)]
95+
96+
97+
def test_execution_options_default():
98+
options = SQLServerExecutionOptions(StatementType.DQL)
99+
assert isinstance(options, ExecutionOptions)
100+
assert options.noscan is None
101+
102+
103+
@pytest.mark.parametrize("options", OPTION_CASES)
104+
@pytest.mark.parametrize("fails", [False, True], ids=["success", "error"])
105+
@pytest.mark.parametrize("outer", [None, True, False])
106+
def test_sync_execution_restores_cursor_and_context(monkeypatch, options, fails, outer):
107+
backend = make_backend(SQLServerBackend)
108+
assert backend._execution_noscan.get() is None
109+
noscan = getattr(options, "noscan", None)
110+
previous = noscan is not True
111+
cursor = NativeCursor(previous)
112+
error = RuntimeError("execution failed")
113+
executions = []
114+
delegated = []
115+
116+
def execute(sql, params):
117+
executions.append((sql, params, cursor._noscan))
118+
if fails:
119+
raise error
120+
121+
cursor.execute = execute
122+
123+
def core_execute(self, sql, params, *, options):
124+
delegated.append((sql, params, options, self._execution_noscan.get()))
125+
return self._execute_query(cursor, sql, params)
126+
127+
monkeypatch.setattr(StorageBackend, "execute", core_execute)
128+
token = backend._execution_noscan.set(outer)
129+
try:
130+
if fails:
131+
with pytest.raises(RuntimeError, match="execution failed") as caught:
132+
backend.execute(SQL, PARAMS, options=options)
133+
assert caught.value is error
134+
else:
135+
assert backend.execute(SQL, PARAMS, options=options) is cursor
136+
assert backend._execution_noscan.get() is outer
137+
assert cursor._noscan is previous
138+
assert cursor.accesses == expected_accesses(noscan, previous)
139+
assert delegated == [(SQL, PARAMS, options, noscan)]
140+
assert delegated[0][2] is options
141+
assert executions == [(SQL, PARAMS, previous if noscan is None else noscan)]
142+
finally:
143+
backend._execution_noscan.reset(token)
144+
145+
assert backend._execution_noscan.get() is None
146+
fails = False
147+
cursor.accesses.clear()
148+
assert backend.execute(SQL, PARAMS) is cursor
149+
assert backend._execution_noscan.get() is None
150+
assert executions[-1] == (SQL, PARAMS, previous)
151+
assert len(executions) == 2
152+
assert type(delegated[-1][2]) is ExecutionOptions
153+
assert delegated[-1][3] is None
154+
assert cursor.accesses == []
155+
156+
157+
@pytest.mark.parametrize("options", OPTION_CASES)
158+
@pytest.mark.parametrize("fails", [False, True], ids=["success", "error"])
159+
@pytest.mark.parametrize("previous", [False, True])
160+
@pytest.mark.asyncio
161+
async def test_async_statement_uses_native_noscan(monkeypatch, options, fails, previous):
162+
backend = make_backend(AsyncSQLServerBackend)
163+
noscan = getattr(options, "noscan", None)
164+
cursor = AsyncCursor(previous, RuntimeError("execution failed") if fails else None)
165+
monkeypatch.setattr(backend, "_get_cursor", AsyncMock(return_value=cursor))
166+
monkeypatch.setattr(backend, "_handle_auto_commit_if_needed", AsyncMock())
167+
168+
if fails:
169+
with pytest.raises(DatabaseError, match="execution failed"):
170+
await backend.execute(SQL, PARAMS, options=options)
171+
else:
172+
result = await backend.execute(SQL, PARAMS, options=options)
173+
assert result.affected_rows == 0
174+
assert result.data is None
175+
176+
assert cursor.executions == [
177+
(SQL, PARAMS, previous if noscan is None else noscan, "wrapper must not change")
178+
]
179+
assert cursor._impl._noscan is previous
180+
assert cursor.exit_value is previous
181+
assert cursor.noscan == "wrapper must not change"
182+
assert cursor._impl.accesses == expected_accesses(noscan, previous)
183+
assert cursor.operations == ([] if noscan is None else [
184+
(getattr, (cursor._impl, "noscan")),
185+
(setattr, (cursor._impl, "noscan", noscan)),
186+
(setattr, (cursor._impl, "noscan", previous)),
187+
])
188+
189+
cursor.error = None
190+
cursor.operations.clear()
191+
cursor._impl.accesses.clear()
192+
await backend.execute(SQL, PARAMS)
193+
assert cursor.executions[-1] == (SQL, PARAMS, previous, "wrapper must not change")
194+
assert len(cursor.executions) == 2
195+
assert cursor.operations == []
196+
assert cursor._impl.accesses == []
197+
198+
199+
@pytest.mark.parametrize("noscan", [True, False, None])
200+
@pytest.mark.parametrize("fails", [False, True], ids=["success", "error"])
201+
@pytest.mark.asyncio
202+
async def test_async_cursor_noscan_context_restores_native_value(noscan, fails):
203+
backend = make_backend(AsyncSQLServerBackend)
204+
previous = noscan is not True
205+
cursor = AsyncCursor(previous)
206+
error = RuntimeError("context failed")
207+
208+
async def use_context():
209+
async with backend._cursor_noscan(cursor, noscan):
210+
assert cursor._impl._noscan is (previous if noscan is None else noscan)
211+
assert cursor.noscan == "wrapper must not change"
212+
if fails:
213+
raise error
214+
215+
if fails:
216+
with pytest.raises(RuntimeError, match="context failed") as caught:
217+
await use_context()
218+
assert caught.value is error
219+
else:
220+
await use_context()
221+
assert cursor._impl._noscan is previous
222+
assert cursor.noscan == "wrapper must not change"
223+
assert cursor._impl.accesses == expected_accesses(noscan, previous)
224+
225+
226+
@pytest.mark.asyncio
227+
async def test_async_cursor_noscan_none_does_not_require_native_cursor():
228+
backend = make_backend(AsyncSQLServerBackend)
229+
cursor = SimpleNamespace()
230+
async with backend._cursor_noscan(cursor, None):
231+
assert vars(cursor) == {}
232+
assert vars(cursor) == {}

0 commit comments

Comments
 (0)