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.
- singularity/__init__.py +18 -0
- singularity/cli/__init__.py +8 -0
- singularity/cli/_app.py +25 -0
- singularity/cli/config.py +142 -0
- singularity/cli/docs_generate.py +196 -0
- singularity/cli/generate.py +210 -0
- singularity/exceptions.py +13 -0
- singularity/executor.py +209 -0
- singularity/introspector.py +108 -0
- singularity/model_generator.py +551 -0
- singularity/types.py +73 -0
- singularity/version/__init__.py +80 -0
- singularity/version/_azure.py +122 -0
- singularity/version/_base.py +73 -0
- singularity/version/_legacy.py +150 -0
- singularity/version/_modern.py +122 -0
- singularitysql-0.1.0.dist-info/METADATA +220 -0
- singularitysql-0.1.0.dist-info/RECORD +22 -0
- singularitysql-0.1.0.dist-info/WHEEL +5 -0
- singularitysql-0.1.0.dist-info/entry_points.txt +2 -0
- singularitysql-0.1.0.dist-info/licenses/LICENSE +21 -0
- singularitysql-0.1.0.dist-info/top_level.txt +1 -0
singularity/executor.py
ADDED
|
@@ -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)
|