singularitysql 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,209 @@
1
+ """Execute stored procedures and map results to Pydantic models.
2
+
3
+ Provides the runtime machinery for the ``from_db()`` classmethod
4
+ generated on Pydantic models.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel
12
+
13
+
14
+ def execute_sp(
15
+ conn_str: str,
16
+ sp_name: str,
17
+ model: type[BaseModel],
18
+ input_params: dict[str, Any] | None = None,
19
+ output_param_names: list[str] | None = None,
20
+ param_order: list[str] | None = None,
21
+ ) -> list[BaseModel]:
22
+ """Execute a stored procedure and map result rows to model instances.
23
+
24
+ Handles both result sets and OUTPUT parameters.
25
+
26
+ Args:
27
+ conn_str: A pyodbc-compatible connection string.
28
+ sp_name: The stored procedure name (schema-qualified if applicable).
29
+ model: The Pydantic model class to map each result row to.
30
+ input_params: Input parameter values keyed by parameter name
31
+ (with or without ``@`` prefix).
32
+ output_param_names: List of OUTPUT parameter names (with ``@`` prefix).
33
+ param_order: Ordered list of ALL parameter names (input + output)
34
+ as defined by the SP. This ensures correct positional binding.
35
+
36
+ Returns:
37
+ A list of model instances, one per result row.
38
+ When the SP has OUTPUT params and no result rows, returns a single
39
+ model instance with output values populated.
40
+ """
41
+ import pyodbc
42
+
43
+ input_params = input_params or {}
44
+ output_param_names = output_param_names or []
45
+ param_order = param_order or list(input_params.keys())
46
+
47
+ conn = pyodbc.connect(conn_str)
48
+ try:
49
+ cursor = conn.cursor()
50
+
51
+ # Build the ordered call_params list
52
+ call_params: list[Any] = []
53
+ for name in param_order:
54
+ if name in output_param_names:
55
+ call_params.append(pyodbc.Output(str))
56
+ else:
57
+ lookup = name.lstrip("@")
58
+ call_params.append(input_params.get(name) or input_params.get(lookup))
59
+
60
+ # Build EXEC with named parameters
61
+ named_parts: list[str] = []
62
+ for name in param_order:
63
+ if name in output_param_names:
64
+ named_parts.append(f"{name}=? OUTPUT")
65
+ else:
66
+ named_parts.append(f"{name}=?")
67
+
68
+ tsql = f"EXEC {sp_name} {', '.join(named_parts)}"
69
+ cursor.execute(tsql, call_params)
70
+
71
+ # Read result rows
72
+ db_columns = [col[0] for col in cursor.description] if cursor.description else []
73
+
74
+ result: list[BaseModel] = []
75
+ for row in cursor.fetchall():
76
+ row_data: dict[str, Any] = {}
77
+ for idx, col_name in enumerate(db_columns):
78
+ row_data[col_name] = row[idx] # None stays None
79
+ result.append(model(**row_data))
80
+
81
+ # If no result rows but there are output params, create a single instance
82
+ if not result and output_param_names:
83
+ result.append(model())
84
+
85
+ # Populate output param values on each result instance (or the single one)
86
+ if output_param_names:
87
+ for inst in result:
88
+ for name in output_param_names:
89
+ clean = name.lstrip("@")
90
+ idx = param_order.index(name)
91
+ val = call_params[idx].getvalue()
92
+ setattr(inst, clean, val)
93
+
94
+ return result
95
+ finally:
96
+ conn.close()
97
+
98
+
99
+ def execute_sp_multi(
100
+ conn_str: str,
101
+ sp_name: str,
102
+ models: list[type[BaseModel]],
103
+ input_params: dict[str, Any] | None = None,
104
+ output_param_names: list[str] | None = None,
105
+ param_order: list[str] | None = None,
106
+ ) -> list[tuple[BaseModel, ...]]:
107
+ """Execute a stored procedure with multiple result sets.
108
+
109
+ Executes the SP, iterates through all result sets using
110
+ ``cursor.nextset()``, and maps each result set to its corresponding
111
+ model class. Returns a list of tuples — one tuple per row cycle
112
+ across result sets.
113
+
114
+ Args:
115
+ conn_str: A pyodbc-compatible connection string.
116
+ sp_name: The stored procedure name.
117
+ models: List of model classes, one per expected result set.
118
+ input_params: Input parameter values.
119
+ output_param_names: List of OUTPUT parameter names.
120
+ param_order: Ordered list of ALL parameter names.
121
+
122
+ Returns:
123
+ A list of tuples. Each tuple contains one instance per model,
124
+ in the same order as ``models``. Stops at the shortest result
125
+ set if they differ in length.
126
+ """
127
+ import pyodbc
128
+
129
+ input_params = input_params or {}
130
+ output_param_names = output_param_names or []
131
+ param_order = param_order or list(input_params.keys())
132
+
133
+ conn = pyodbc.connect(conn_str)
134
+ try:
135
+ cursor = conn.cursor()
136
+
137
+ # Build the ordered call_params list
138
+ call_params: list[Any] = []
139
+ for name in param_order:
140
+ if name in output_param_names:
141
+ call_params.append(pyodbc.Output(str))
142
+ else:
143
+ lookup = name.lstrip("@")
144
+ call_params.append(input_params.get(name) or input_params.get(lookup))
145
+
146
+ # Build EXEC with named parameters
147
+ named_parts: list[str] = []
148
+ for name in param_order:
149
+ if name in output_param_names:
150
+ named_parts.append(f"{name}=? OUTPUT")
151
+ else:
152
+ named_parts.append(f"{name}=?")
153
+
154
+ tsql = f"EXEC {sp_name} {', '.join(named_parts)}"
155
+ cursor.execute(tsql, call_params)
156
+
157
+ # Collect all result sets with column names
158
+ all_rs_columns: list[list[str]] = []
159
+ all_rs_rows: list[list[list[Any]]] = []
160
+
161
+ while True:
162
+ columns = [col[0] for col in cursor.description] if cursor.description else []
163
+ if not columns:
164
+ if not cursor.nextset():
165
+ break
166
+ continue
167
+ all_rs_columns.append(columns)
168
+ rows_data: list[list[Any]] = [list(row) for row in cursor.fetchall()]
169
+ all_rs_rows.append(rows_data)
170
+ if not cursor.nextset():
171
+ break
172
+
173
+ # Build tuples — one per row cycle across result sets
174
+ result: list[tuple[BaseModel, ...]] = []
175
+ if all_rs_rows:
176
+ min_len = min(len(rs) for rs in all_rs_rows)
177
+ for row_idx in range(min_len):
178
+ instances: list[BaseModel] = []
179
+ for rs_idx, rs_rows in enumerate(all_rs_rows):
180
+ model_cls = models[rs_idx] if rs_idx < len(models) else models[-1]
181
+ row_data = dict(zip(all_rs_columns[rs_idx], rs_rows[row_idx], strict=True))
182
+ instances.append(model_cls(**row_data))
183
+ result.append(tuple(instances))
184
+
185
+ # Populate output param values
186
+ if output_param_names:
187
+ for inst_tuple in result:
188
+ for name in output_param_names:
189
+ clean = name.lstrip("@")
190
+ idx = param_order.index(name)
191
+ val = call_params[idx].getvalue()
192
+ for inst in inst_tuple:
193
+ if clean in inst.model_fields:
194
+ setattr(inst, clean, val)
195
+
196
+ # If no result rows but there are output params
197
+ if not result and output_param_names:
198
+ inst = models[0]()
199
+ for name in output_param_names:
200
+ clean = name.lstrip("@")
201
+ idx = param_order.index(name)
202
+ val = call_params[idx].getvalue()
203
+ if clean in inst.model_fields:
204
+ setattr(inst, clean, val)
205
+ result.append((inst,))
206
+
207
+ return result
208
+ finally:
209
+ conn.close()
@@ -0,0 +1,108 @@
1
+ """SQL Server stored procedure introspector — facade over version-specific strategies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from singularity.types import SPMetadata
8
+ from singularity.version import (
9
+ ServerVersion,
10
+ _select_strategy,
11
+ parse_version_string,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ import pyodbc
16
+
17
+
18
+ class SQLServerIntrospector:
19
+ """Facade for introspecting SQL Server stored procedures.
20
+
21
+ Manages a pyodbc connection, auto-detects the SQL Server version,
22
+ and delegates to the appropriate version-specific introspection strategy.
23
+
24
+ Usage:
25
+ introspector = SQLServerIntrospector("DRIVER={ODBC Driver 18};SERVER=...")
26
+ introspector.connect()
27
+ meta = introspector.introspect("usp_GetOrders")
28
+ """
29
+
30
+ def __init__(self, conn_str: str) -> None:
31
+ """Initialise the introspector with a connection string.
32
+
33
+ Args:
34
+ conn_str: A pyodbc-compatible connection string.
35
+ """
36
+ self._conn_str: str = conn_str
37
+ self._connection: pyodbc.Connection | None = None
38
+ self._version: ServerVersion | None = None
39
+ self._strategy: object | None = None
40
+
41
+ def connect(self) -> object:
42
+ """Establish a connection to SQL Server.
43
+
44
+ Returns:
45
+ The pyodbc Connection object.
46
+
47
+ Raises:
48
+ ConnectionError: If the connection fails.
49
+ """
50
+ import pyodbc
51
+
52
+ try:
53
+ conn = pyodbc.connect(self._conn_str, autocommit=True)
54
+ except pyodbc.Error as exc:
55
+ raise ConnectionError(
56
+ f"Failed to connect to SQL Server: {exc}"
57
+ ) from exc
58
+
59
+ self._connection = conn
60
+ return conn
61
+
62
+ def detect_version(self) -> ServerVersion:
63
+ """Detect the SQL Server version by querying @@VERSION.
64
+
65
+ Returns:
66
+ A ServerVersion enum value.
67
+
68
+ Raises:
69
+ RuntimeError: If not connected.
70
+ """
71
+ if self._connection is None:
72
+ raise RuntimeError(
73
+ "Not connected. Call connect() before detect_version()."
74
+ )
75
+
76
+ cursor = self._connection.cursor()
77
+ cursor.execute("SELECT @@VERSION")
78
+ version_output: str = cursor.fetchone()[0]
79
+
80
+ self._version = parse_version_string(version_output)
81
+ return self._version
82
+
83
+ def introspect(self, sp_name: str) -> SPMetadata:
84
+ """Introspect a stored procedure and return its metadata.
85
+
86
+ Calls connect() and detect_version() automatically if not yet connected.
87
+
88
+ Args:
89
+ sp_name: The stored procedure name (with or without schema prefix).
90
+
91
+ Returns:
92
+ SPMetadata containing parameter and result set column info.
93
+
94
+ Raises:
95
+ SpNotFoundError: If the procedure does not exist.
96
+ ConnectionError: If connection fails.
97
+ """
98
+ if self._connection is None:
99
+ self.connect()
100
+ assert self._connection is not None # narrowed by connect()
101
+
102
+ if self._version is None:
103
+ self.detect_version()
104
+ assert self._version is not None # narrowed by detect_version()
105
+
106
+ cursor = self._connection.cursor()
107
+ strategy = _select_strategy(self._version)
108
+ return strategy.introspect(sp_name, cursor)