soda-sqlserver 4.21.0__tar.gz → 4.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soda-sqlserver
3
- Version: 4.21.0
3
+ Version: 4.22.0
4
4
  Summary: Soda SQL Server V4
5
5
  Author-email: "Soda Data N.V." <info@soda.io>
6
6
  License: Proprietary
7
7
  Requires-Python: >=3.10
8
- Requires-Dist: soda-core==4.21.0
8
+ Requires-Dist: soda-core==4.22.0
9
9
  Requires-Dist: pyodbc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "soda-sqlserver"
3
- version = "4.21.0"
3
+ version = "4.22.0"
4
4
  description = "Soda SQL Server V4"
5
5
  requires-python = ">=3.10"
6
6
  license = {text = "Proprietary"}
@@ -8,7 +8,7 @@ authors = [
8
8
  {name = "Soda Data N.V.", email = "info@soda.io"}
9
9
  ]
10
10
  dependencies = [
11
- "soda-core==4.21.0",
11
+ "soda-core==4.22.0",
12
12
  "pyodbc",
13
13
  ]
14
14
 
@@ -318,7 +318,8 @@ class SqlServerSqlDialect(SqlDialect, sqlglot_dialect="tsql"):
318
318
  # see: https://learn.microsoft.com/en-us/sql/relational-databases/collations/collation-and-unicode-support?view=sql-server-ver17
319
319
  # CS: Case sensitive; AS: Accent sensitive
320
320
  # The default is SQL_Latin1_General_Cp1_CI_AS (case-insensitive), we replcae with a case sensitive collation
321
- return f"PATINDEX ('%{regex_pattern}%', {expression} COLLATE SQL_Latin1_General_Cp1_CS_AS) > 0"
321
+ pattern_literal: str = self.literal_string(f"%{regex_pattern}%")
322
+ return f"PATINDEX ({pattern_literal}, {expression} COLLATE SQL_Latin1_General_Cp1_CS_AS) > 0"
322
323
 
323
324
  def supports_regex_advanced(self) -> bool:
324
325
  return False
@@ -1,15 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import random
4
5
  import struct
6
+ import time
5
7
  from abc import ABC
6
8
  from datetime import datetime, timedelta, timezone, tzinfo
7
- from typing import Any, Literal, Optional, Union
9
+ from typing import Any, Callable, Literal, Optional, TypeVar, Union
8
10
 
9
11
  import pyodbc
10
12
  from pydantic import Field, SecretStr
11
13
  from soda_core.__version__ import SODA_CORE_VERSION
12
14
  from soda_core.common.data_source_connection import DataSourceConnection
15
+ from soda_core.common.data_source_results import QueryResult
13
16
  from soda_core.common.exceptions import DataSourceConnectionException
14
17
  from soda_core.common.logging_constants import soda_logger
15
18
  from soda_core.model.data_source.data_source import DataSourceBase
@@ -20,6 +23,9 @@ from soda_core.model.data_source.data_source_connection_properties import (
20
23
  logger: logging.Logger = soda_logger
21
24
 
22
25
 
26
+ T = TypeVar("T")
27
+
28
+
23
29
  CONTEXT_AUTHENTICATION_DESCRIPTION = "Use context authentication"
24
30
  USER_DESCRIPTION = "Username for authentication"
25
31
  DEFAULT_PORT = 1433
@@ -106,6 +112,16 @@ def handle_datetimeoffset(dto_value):
106
112
 
107
113
 
108
114
  class SqlServerDataSourceConnection(DataSourceConnection):
115
+ # SQL Server resolves lock conflicts by killing one participant with error 1205
116
+ # (SQLSTATE 40001) and rolling its transaction back; Microsoft's guidance is to
117
+ # rerun the killed transaction. Concurrent DDL and catalog scans in the same
118
+ # database can deadlock on the system base tables, so statements are retried a
119
+ # few times before the error is propagated. The pause is drawn uniformly from
120
+ # an exponentially growing window (full jitter) so that the parties of a killed
121
+ # deadlock don't retry in lockstep and immediately deadlock again.
122
+ DEADLOCK_MAX_ATTEMPTS: int = 3
123
+ DEADLOCK_RETRY_BACKOFF_SECONDS: float = 0.1
124
+
109
125
  def __init__(self, name: str, connection_properties: DataSourceConnectionProperties):
110
126
  # Set before super().__init__(), which auto-opens the connection and
111
127
  # populates these from the live server in _create_connection.
@@ -113,6 +129,45 @@ class SqlServerDataSourceConnection(DataSourceConnection):
113
129
  self.engine_edition: Optional[int] = None
114
130
  super().__init__(name, connection_properties)
115
131
 
132
+ def execute_query(self, sql: str, log_query: bool = True) -> QueryResult:
133
+ execute = super().execute_query
134
+ return self._execute_with_deadlock_retry(lambda: execute(sql, log_query))
135
+
136
+ def execute_update(self, sql: str, log_query: bool = True) -> int:
137
+ execute = super().execute_update
138
+ return self._execute_with_deadlock_retry(lambda: execute(sql, log_query))
139
+
140
+ @staticmethod
141
+ def _is_deadlock_error(e: pyodbc.Error) -> bool:
142
+ # pyodbc error args are (sqlstate, message); 40001 is the serialization
143
+ # failure SQLSTATE that SQL Server raises for deadlock victims (1205).
144
+ return bool(e.args) and e.args[0] == "40001"
145
+
146
+ def _execute_with_deadlock_retry(self, operation: Callable[[], T]) -> T:
147
+ """Assumes ``operation`` is a single-statement unit of work: recovery
148
+ issues a connection-wide rollback, discarding any earlier uncommitted
149
+ statements on this connection (SQL Server runs autocommit=False).
150
+ Only the buffered paths (execute_query/execute_update) are wrapped;
151
+ the callback/iterator paths (execute_query_one_by_one*,
152
+ execute_query_iterate) may have already delivered rows when a deadlock
153
+ strikes mid-fetch, so re-executing them would double-process rows."""
154
+ for attempt in range(1, max(self.DEADLOCK_MAX_ATTEMPTS, 1) + 1):
155
+ try:
156
+ return operation()
157
+ except pyodbc.Error as e:
158
+ if not self._is_deadlock_error(e) or attempt == self.DEADLOCK_MAX_ATTEMPTS:
159
+ raise
160
+ logger.warning(
161
+ f"Deadlock victim on '{self.name}' "
162
+ f"(attempt {attempt}/{self.DEADLOCK_MAX_ATTEMPTS}), retrying: {e}"
163
+ )
164
+ try:
165
+ self.rollback()
166
+ except Exception as rollback_error:
167
+ logger.warning(f"Rollback after deadlock on '{self.name}' failed: {rollback_error}")
168
+ backoff_cap = self.DEADLOCK_RETRY_BACKOFF_SECONDS * (2 ** (attempt - 1))
169
+ time.sleep(random.uniform(0, backoff_cap))
170
+
116
171
  # Normalize pyodbc.Row objects so downstream consumers see plain tuples.
117
172
  def _format_rows(self, rows: list[tuple]) -> list[tuple]:
118
173
  return [self._format_row(row) for row in rows]
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soda-sqlserver
3
- Version: 4.21.0
3
+ Version: 4.22.0
4
4
  Summary: Soda SQL Server V4
5
5
  Author-email: "Soda Data N.V." <info@soda.io>
6
6
  License: Proprietary
7
7
  Requires-Python: >=3.10
8
- Requires-Dist: soda-core==4.21.0
8
+ Requires-Dist: soda-core==4.22.0
9
9
  Requires-Dist: pyodbc
@@ -0,0 +1,2 @@
1
+ soda-core==4.22.0
2
+ pyodbc
@@ -1,2 +0,0 @@
1
- soda-core==4.21.0
2
- pyodbc