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
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Pydantic model generation from stored procedure metadata.
|
|
2
|
+
|
|
3
|
+
Provides a single generate_model() function with two output modes:
|
|
4
|
+
- "dynamic": returns a runtime BaseModel subclass via create_model()
|
|
5
|
+
- "source": returns a valid Python source code string
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import warnings
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field, create_model
|
|
16
|
+
|
|
17
|
+
from singularity.types import SPMetadata
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Type mapping
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
TYPE_MAP: dict[str, type] = {
|
|
24
|
+
# Integer types
|
|
25
|
+
"INT": int,
|
|
26
|
+
"BIGINT": int,
|
|
27
|
+
"SMALLINT": int,
|
|
28
|
+
"TINYINT": int,
|
|
29
|
+
# String types
|
|
30
|
+
"VARCHAR": str,
|
|
31
|
+
"NVARCHAR": str,
|
|
32
|
+
"CHAR": str,
|
|
33
|
+
"NCHAR": str,
|
|
34
|
+
# Date/time types
|
|
35
|
+
"DATETIME": datetime,
|
|
36
|
+
"DATETIME2": datetime,
|
|
37
|
+
"DATE": datetime,
|
|
38
|
+
"SMALLDATETIME": datetime,
|
|
39
|
+
# Boolean
|
|
40
|
+
"BIT": bool,
|
|
41
|
+
# Float / decimal types
|
|
42
|
+
"DECIMAL": float,
|
|
43
|
+
"NUMERIC": float,
|
|
44
|
+
"FLOAT": float,
|
|
45
|
+
"REAL": float,
|
|
46
|
+
"MONEY": float,
|
|
47
|
+
"SMALLMONEY": float,
|
|
48
|
+
# UUID / unique identifier
|
|
49
|
+
"UNIQUEIDENTIFIER": str,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Types whose Pydantic annotation differs from the Python type
|
|
53
|
+
PYDANTIC_TYPE_MAP: dict[type, type] = {
|
|
54
|
+
datetime: datetime,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Field name sanitization
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
_RESERVED_WORDS: set[str] = {
|
|
62
|
+
"and", "as", "assert", "async", "await", "break", "class", "continue",
|
|
63
|
+
"def", "del", "elif", "else", "except", "finally", "for", "from",
|
|
64
|
+
"global", "if", "import", "in", "is", "lambda", "nonlocal", "not",
|
|
65
|
+
"or", "pass", "raise", "return", "try", "while", "with", "yield",
|
|
66
|
+
"True", "False", "None",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_snake_case(name: str) -> str:
|
|
71
|
+
"""Convert a name to snake_case."""
|
|
72
|
+
# Insert underscore before uppercase letters preceded by a lowercase
|
|
73
|
+
# letter or digit, then lowercase
|
|
74
|
+
s = re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", name)
|
|
75
|
+
# Also handle acronyms followed by uppercase (e.g. "XMLParser" → "xml_parser")
|
|
76
|
+
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)
|
|
77
|
+
return s.lower()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _to_camel_case(name: str) -> str:
|
|
81
|
+
"""Convert a name to camelCase (first letter lowercase)."""
|
|
82
|
+
snake = _to_snake_case(name)
|
|
83
|
+
parts = snake.split("_")
|
|
84
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _to_pascal_case(name: str) -> str:
|
|
88
|
+
"""Convert a name to PascalCase (first letter uppercase)."""
|
|
89
|
+
snake = _to_snake_case(name)
|
|
90
|
+
return "".join(p.capitalize() for p in snake.split("_"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def sanitize_field_name(
|
|
94
|
+
name: str,
|
|
95
|
+
convention: Literal["snake_case", "camelCase", "PascalCase"] = "snake_case",
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Convert a SQL identifier to a valid Python field name.
|
|
98
|
+
|
|
99
|
+
Steps:
|
|
100
|
+
1. Strip leading '@' characters (parameter prefix)
|
|
101
|
+
2. Replace non-alphanumeric characters (except underscores) with '_'
|
|
102
|
+
3. Collapse consecutive underscores
|
|
103
|
+
4. Strip leading/trailing underscores
|
|
104
|
+
5. Apply naming convention (snake_case / camelCase / PascalCase)
|
|
105
|
+
6. Prefix with 'field_' if the result is a Python reserved word
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
name: The raw SQL identifier (e.g. '@OrderId', 'Customer Name').
|
|
109
|
+
convention: The target naming convention.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A valid Python identifier.
|
|
113
|
+
"""
|
|
114
|
+
# Strip @ prefix (parameter marker)
|
|
115
|
+
clean = name.lstrip("@")
|
|
116
|
+
|
|
117
|
+
# Replace non-alphanumeric, non-underscore chars with underscores
|
|
118
|
+
clean = re.sub(r"[^a-zA-Z0-9_]", "_", clean)
|
|
119
|
+
|
|
120
|
+
# Collapse multiple underscores
|
|
121
|
+
clean = re.sub(r"_+", "_", clean)
|
|
122
|
+
|
|
123
|
+
# Strip leading/trailing underscores
|
|
124
|
+
clean = clean.strip("_")
|
|
125
|
+
|
|
126
|
+
# If empty after sanitization, use a generic name
|
|
127
|
+
if not clean:
|
|
128
|
+
clean = "field"
|
|
129
|
+
|
|
130
|
+
# Apply naming convention
|
|
131
|
+
if convention == "camelCase":
|
|
132
|
+
clean = _to_camel_case(clean)
|
|
133
|
+
elif convention == "PascalCase":
|
|
134
|
+
clean = _to_pascal_case(clean)
|
|
135
|
+
else:
|
|
136
|
+
# snake_case — ensure it's truly snake_case
|
|
137
|
+
clean = _to_snake_case(clean)
|
|
138
|
+
|
|
139
|
+
# Ensure it doesn't start with a digit
|
|
140
|
+
if clean and clean[0].isdigit():
|
|
141
|
+
clean = f"field_{clean}"
|
|
142
|
+
|
|
143
|
+
# Avoid reserved words (case-insensitive check — after snake_case everything is lowercase)
|
|
144
|
+
if clean.lower() in {w.lower() for w in _RESERVED_WORDS}:
|
|
145
|
+
clean = f"field_{clean}"
|
|
146
|
+
|
|
147
|
+
return clean
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Source code type name helpers
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
_SOURCE_TYPE_NAMES: dict[type, str] = {
|
|
155
|
+
int: "int",
|
|
156
|
+
str: "str",
|
|
157
|
+
datetime: "datetime",
|
|
158
|
+
bool: "bool",
|
|
159
|
+
float: "float",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _py_type_name(tp: type) -> str:
|
|
164
|
+
"""Return the Python source-level name for a type."""
|
|
165
|
+
return _SOURCE_TYPE_NAMES.get(tp, "str")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Model generation
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def generate_model(
|
|
174
|
+
meta: SPMetadata,
|
|
175
|
+
mode: Literal["dynamic", "source"] = "source",
|
|
176
|
+
naming_convention: Literal["snake_case", "camelCase", "PascalCase"] = "snake_case",
|
|
177
|
+
result_set_index: int = 0,
|
|
178
|
+
) -> type[BaseModel] | str:
|
|
179
|
+
"""Generate a Pydantic v2 model from stored procedure metadata.
|
|
180
|
+
|
|
181
|
+
Two output modes:
|
|
182
|
+
- **dynamic**: Returns a BaseModel subclass (created via create_model()).
|
|
183
|
+
The class can be used at runtime for type-safe data access.
|
|
184
|
+
- **source**: Returns a valid Python source code string. The string
|
|
185
|
+
contains proper imports, a class definition, typed fields, and a
|
|
186
|
+
``from_db()`` classmethod to execute the SP and return typed instances.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
meta: SPMetadata describing the stored procedure's result set.
|
|
190
|
+
mode: Output mode — "dynamic" or "source".
|
|
191
|
+
naming_convention: Field naming convention:
|
|
192
|
+
- "snake_case" (default): order_id, customer_name
|
|
193
|
+
- "camelCase": orderId, customerName
|
|
194
|
+
- "PascalCase": OrderId, CustomerName
|
|
195
|
+
result_set_index: Which result set to generate a model for
|
|
196
|
+
(0 = first). Ignored when ``mode="source"`` with multiple
|
|
197
|
+
result sets — use ``generate_all_models()`` instead.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
A BaseModel subclass (dynamic mode) or a Python source string (source mode).
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
>>> meta = SPMetadata(name="usp_GetOrders", result_sets=[[
|
|
204
|
+
... ColumnInfo(name="OrderId", sql_type="INT", nullable=False),
|
|
205
|
+
... ColumnInfo(name="Total", sql_type="DECIMAL", nullable=True),
|
|
206
|
+
... ]])
|
|
207
|
+
>>> model = generate_model(meta, mode="dynamic")
|
|
208
|
+
>>> isinstance(model, type) and issubclass(model, BaseModel)
|
|
209
|
+
True
|
|
210
|
+
"""
|
|
211
|
+
if mode == "dynamic":
|
|
212
|
+
return _generate_dynamic(meta, naming_convention)
|
|
213
|
+
return _generate_source(meta, naming_convention)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def generate_all_models(
|
|
217
|
+
meta: SPMetadata,
|
|
218
|
+
mode: Literal["dynamic", "source"] = "source",
|
|
219
|
+
naming_convention: Literal["snake_case", "camelCase", "PascalCase"] = "snake_case",
|
|
220
|
+
) -> Any:
|
|
221
|
+
"""Generate models for ALL result sets of a stored procedure.
|
|
222
|
+
|
|
223
|
+
For multi-result-set SPs, generates one model class per result set.
|
|
224
|
+
For source mode, returns a list of source strings (one per class).
|
|
225
|
+
For dynamic mode, returns a tuple of model classes.
|
|
226
|
+
|
|
227
|
+
The last result set model includes ``from_db()`` which handles
|
|
228
|
+
multiple result sets at execution time.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
In dynamic mode: a single model or tuple of models.
|
|
232
|
+
In source mode: a single source string or list of source strings.
|
|
233
|
+
"""
|
|
234
|
+
num_rs = len(meta.result_sets)
|
|
235
|
+
|
|
236
|
+
if num_rs <= 1:
|
|
237
|
+
# Single result set — delegate to generate_model
|
|
238
|
+
result = generate_model(meta, mode=mode, naming_convention=naming_convention)
|
|
239
|
+
if mode == "dynamic":
|
|
240
|
+
return (result,)
|
|
241
|
+
return [result]
|
|
242
|
+
|
|
243
|
+
if mode == "dynamic":
|
|
244
|
+
models: list[type[BaseModel]] = []
|
|
245
|
+
for i, rs in enumerate(meta.result_sets):
|
|
246
|
+
temp_meta = meta.model_copy(update={"result_sets": [rs]})
|
|
247
|
+
rs_name = f"{meta.name}_Result{i + 1}"
|
|
248
|
+
m = _generate_dynamic_single_rs(temp_meta, rs_name, naming_convention)
|
|
249
|
+
models.append(m)
|
|
250
|
+
|
|
251
|
+
# Patch from_db on the LAST model to handle all RS
|
|
252
|
+
_patch_from_db_multi(models, meta)
|
|
253
|
+
|
|
254
|
+
return tuple(models)
|
|
255
|
+
|
|
256
|
+
# Source mode — generate one class per result set
|
|
257
|
+
sources: list[str] = []
|
|
258
|
+
for i, rs in enumerate(meta.result_sets):
|
|
259
|
+
temp_meta = meta.model_copy(update={"result_sets": [rs]})
|
|
260
|
+
suffix = f"Result{i + 1}" if i > 0 else ""
|
|
261
|
+
src = _generate_source(temp_meta, naming_convention, class_suffix=suffix)
|
|
262
|
+
sources.append(src)
|
|
263
|
+
|
|
264
|
+
return sources
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Dynamic mode
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _resolve_py_type(sql_type: str) -> type:
|
|
273
|
+
"""Map a SQL Server type string to a Python type.
|
|
274
|
+
|
|
275
|
+
Strips parenthesised qualifiers like (50), (18,2) before lookup.
|
|
276
|
+
Falls back to ``str`` for unknown types.
|
|
277
|
+
"""
|
|
278
|
+
base = sql_type.split("(")[0].strip().upper()
|
|
279
|
+
py_type = TYPE_MAP.get(base)
|
|
280
|
+
if py_type is None:
|
|
281
|
+
warnings.warn(
|
|
282
|
+
f"Unknown SQL Server type '{sql_type}' — falling back to str",
|
|
283
|
+
stacklevel=2,
|
|
284
|
+
)
|
|
285
|
+
return str
|
|
286
|
+
return py_type
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _generate_dynamic(
|
|
290
|
+
meta: SPMetadata,
|
|
291
|
+
naming_convention: str = "snake_case",
|
|
292
|
+
) -> type[BaseModel]:
|
|
293
|
+
"""Generate a runtime BaseModel subclass via create_model() for the first RS."""
|
|
294
|
+
return _generate_dynamic_single_rs(meta, meta.name, naming_convention)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _generate_dynamic_single_rs(
|
|
298
|
+
meta: SPMetadata,
|
|
299
|
+
class_name: str,
|
|
300
|
+
naming_convention: str,
|
|
301
|
+
) -> type[BaseModel]:
|
|
302
|
+
"""Generate a dynamic model for a single result set."""
|
|
303
|
+
fields: dict[str, Any] = {}
|
|
304
|
+
|
|
305
|
+
for col in meta.columns:
|
|
306
|
+
py_type = _resolve_py_type(col.sql_type)
|
|
307
|
+
field_name = sanitize_field_name(col.name, naming_convention) # type: ignore[arg-type]
|
|
308
|
+
|
|
309
|
+
field_kwargs: dict[str, Any] = {}
|
|
310
|
+
if col.description:
|
|
311
|
+
field_kwargs["description"] = col.description
|
|
312
|
+
|
|
313
|
+
if col.nullable:
|
|
314
|
+
if field_kwargs:
|
|
315
|
+
fields[field_name] = (type(None) | py_type, Field(default=None, **field_kwargs))
|
|
316
|
+
else:
|
|
317
|
+
fields[field_name] = (type(None) | py_type, None)
|
|
318
|
+
else:
|
|
319
|
+
if field_kwargs:
|
|
320
|
+
fields[field_name] = (py_type, Field(..., **field_kwargs))
|
|
321
|
+
else:
|
|
322
|
+
fields[field_name] = (py_type, ...)
|
|
323
|
+
|
|
324
|
+
# Add OUTPUT param fields (as Optional[T] = None)
|
|
325
|
+
output_param_names: list[str] = []
|
|
326
|
+
for p in meta.parameters:
|
|
327
|
+
if p.direction in ("OUT", "INOUT"):
|
|
328
|
+
py_type = _resolve_py_type(p.sql_type)
|
|
329
|
+
field_name = sanitize_field_name(p.name, naming_convention) # type: ignore[arg-type]
|
|
330
|
+
output_param_names.append(p.name)
|
|
331
|
+
fields[field_name] = (type(None) | py_type, None)
|
|
332
|
+
|
|
333
|
+
model = create_model(class_name, **fields)
|
|
334
|
+
model._output_param_names = output_param_names
|
|
335
|
+
|
|
336
|
+
# Build param_order for executor
|
|
337
|
+
all_param_names: list[str] = [p.name for p in meta.parameters]
|
|
338
|
+
|
|
339
|
+
# Monkey-patch from_db() onto the dynamic model
|
|
340
|
+
_patch_from_db(model, meta.name, output_param_names, all_param_names)
|
|
341
|
+
|
|
342
|
+
return model # type: ignore[no-any-return]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _patch_from_db(
|
|
346
|
+
model: type[BaseModel],
|
|
347
|
+
sp_name: str,
|
|
348
|
+
output_param_names: list[str] | None = None,
|
|
349
|
+
all_param_names: list[str] | None = None,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Monkey-patch a ``from_db()`` classmethod onto a dynamic model.
|
|
352
|
+
|
|
353
|
+
The patched method delegates to ``singularity.executor.execute_sp``,
|
|
354
|
+
embedding the stored procedure name so callers only pass a connection
|
|
355
|
+
string and parameter values.
|
|
356
|
+
"""
|
|
357
|
+
from singularity.executor import execute_sp
|
|
358
|
+
|
|
359
|
+
output_param_names = output_param_names or []
|
|
360
|
+
all_param_names = all_param_names or []
|
|
361
|
+
|
|
362
|
+
@classmethod # type: ignore[misc]
|
|
363
|
+
def from_db(cls: type[BaseModel], conn_str: str, **params: Any) -> list[BaseModel]:
|
|
364
|
+
return execute_sp(
|
|
365
|
+
conn_str,
|
|
366
|
+
sp_name,
|
|
367
|
+
cls,
|
|
368
|
+
input_params=params,
|
|
369
|
+
output_param_names=output_param_names,
|
|
370
|
+
param_order=all_param_names,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
model.from_db = from_db # type: ignore[attr-defined]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _patch_from_db_multi(
|
|
377
|
+
models: list[type[BaseModel]],
|
|
378
|
+
meta: SPMetadata,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Monkey-patch a ``from_db()`` onto the last model for multi-RS SPs.
|
|
381
|
+
|
|
382
|
+
The patched method returns ``list[tuple[Model1, Model2, ...]]``.
|
|
383
|
+
"""
|
|
384
|
+
from singularity.executor import execute_sp_multi
|
|
385
|
+
|
|
386
|
+
output_param_names = [p.name for p in meta.parameters if p.direction in ("OUT", "INOUT")]
|
|
387
|
+
all_param_names = [p.name for p in meta.parameters]
|
|
388
|
+
|
|
389
|
+
last_model = models[-1]
|
|
390
|
+
|
|
391
|
+
@classmethod # type: ignore[misc]
|
|
392
|
+
def from_db(cls: type[BaseModel], conn_str: str, **params: Any) -> list[Any]:
|
|
393
|
+
return execute_sp_multi(
|
|
394
|
+
conn_str,
|
|
395
|
+
meta.name,
|
|
396
|
+
models,
|
|
397
|
+
input_params=params,
|
|
398
|
+
output_param_names=output_param_names,
|
|
399
|
+
param_order=all_param_names,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
last_model.from_db = from_db # type: ignore[attr-defined]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
# Source mode
|
|
407
|
+
# ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _generate_source(
|
|
411
|
+
meta: SPMetadata,
|
|
412
|
+
naming_convention: str = "snake_case",
|
|
413
|
+
class_suffix: str = "",
|
|
414
|
+
) -> str:
|
|
415
|
+
"""Generate a Python source code string for a Pydantic model."""
|
|
416
|
+
class_name = sanitize_field_name(meta.name, "PascalCase") + class_suffix
|
|
417
|
+
rs = meta.columns # first result set via backward-compat property
|
|
418
|
+
|
|
419
|
+
has_descriptions = any(col.description for col in rs)
|
|
420
|
+
num_rs = len(meta.result_sets)
|
|
421
|
+
is_single_rs = num_rs <= 1
|
|
422
|
+
|
|
423
|
+
lines: list[str] = []
|
|
424
|
+
lines.append("from __future__ import annotations")
|
|
425
|
+
lines.append("")
|
|
426
|
+
lines.append("from datetime import datetime")
|
|
427
|
+
lines.append("from typing import Any, Optional")
|
|
428
|
+
lines.append("from pydantic import BaseModel")
|
|
429
|
+
if has_descriptions:
|
|
430
|
+
lines.append("from pydantic import Field")
|
|
431
|
+
if is_single_rs:
|
|
432
|
+
lines.append("from singularity.executor import execute_sp")
|
|
433
|
+
else:
|
|
434
|
+
lines.append("from singularity.executor import execute_sp_multi")
|
|
435
|
+
lines.append("")
|
|
436
|
+
|
|
437
|
+
# Identify OUTPUT params
|
|
438
|
+
output_params = [p for p in meta.parameters if p.direction in ("OUT", "INOUT")]
|
|
439
|
+
output_param_names = [p.name for p in output_params]
|
|
440
|
+
all_param_names = [p.name for p in meta.parameters]
|
|
441
|
+
|
|
442
|
+
if not rs:
|
|
443
|
+
lines.append(f"class {class_name}(BaseModel):")
|
|
444
|
+
lines.append(f" _sp_name = {repr(meta.name)}")
|
|
445
|
+
lines.append(f" _output_param_names = {repr(output_param_names)}")
|
|
446
|
+
lines.append("")
|
|
447
|
+
for p in output_params:
|
|
448
|
+
field_name = sanitize_field_name(p.name, naming_convention) # type: ignore[arg-type]
|
|
449
|
+
lines.append(f" {field_name}: Optional[Any] = None")
|
|
450
|
+
if output_params:
|
|
451
|
+
lines.append("")
|
|
452
|
+
if is_single_rs:
|
|
453
|
+
lines.append(" @classmethod")
|
|
454
|
+
lines.append(" def from_db(cls, conn_str: str, **params: Any) -> list[BaseModel]:")
|
|
455
|
+
else:
|
|
456
|
+
lines.append(" @classmethod")
|
|
457
|
+
lines.append(
|
|
458
|
+
" def from_db("
|
|
459
|
+
"cls, conn_str: str, **params: Any"
|
|
460
|
+
") -> list[Any]:"
|
|
461
|
+
)
|
|
462
|
+
lines.append(' """Execute the stored procedure and return typed instances."""')
|
|
463
|
+
if is_single_rs:
|
|
464
|
+
lines.append(" return execute_sp(")
|
|
465
|
+
else:
|
|
466
|
+
lines.append(" return execute_sp_multi(")
|
|
467
|
+
lines.append(" conn_str,")
|
|
468
|
+
lines.append(" cls._sp_name,")
|
|
469
|
+
if not is_single_rs:
|
|
470
|
+
lines.append(" _MODELS,")
|
|
471
|
+
lines.append(" cls,")
|
|
472
|
+
lines.append(" input_params=params,")
|
|
473
|
+
lines.append(f" output_param_names={repr(output_param_names)},")
|
|
474
|
+
lines.append(f" param_order={repr(all_param_names)},")
|
|
475
|
+
lines.append(" )")
|
|
476
|
+
lines.append("")
|
|
477
|
+
lines.append(" pass")
|
|
478
|
+
lines.append("")
|
|
479
|
+
return "\n".join(lines) + "\n"
|
|
480
|
+
|
|
481
|
+
lines.append(f"class {class_name}(BaseModel):")
|
|
482
|
+
lines.append(f' """Model generated from stored procedure: {meta.name}.')
|
|
483
|
+
lines.append("")
|
|
484
|
+
lines.append(' Auto-generated by Singularity.')
|
|
485
|
+
lines.append(' """')
|
|
486
|
+
lines.append("")
|
|
487
|
+
lines.append(f" _sp_name = {repr(meta.name)}")
|
|
488
|
+
lines.append("")
|
|
489
|
+
|
|
490
|
+
for col in rs:
|
|
491
|
+
py_type = _resolve_py_type(col.sql_type)
|
|
492
|
+
field_name = sanitize_field_name(col.name, naming_convention) # type: ignore[arg-type]
|
|
493
|
+
type_name = _py_type_name(py_type)
|
|
494
|
+
|
|
495
|
+
if col.description:
|
|
496
|
+
if col.nullable:
|
|
497
|
+
lines.append(
|
|
498
|
+
f" {field_name}: Optional[{type_name}] = Field("
|
|
499
|
+
f"default=None, description={repr(col.description)})"
|
|
500
|
+
)
|
|
501
|
+
else:
|
|
502
|
+
lines.append(
|
|
503
|
+
f" {field_name}: {type_name} = Field("
|
|
504
|
+
f"description={repr(col.description)})"
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
if col.nullable:
|
|
508
|
+
lines.append(f" {field_name}: Optional[{type_name}] = None")
|
|
509
|
+
else:
|
|
510
|
+
lines.append(f" {field_name}: {type_name}")
|
|
511
|
+
|
|
512
|
+
# Add OUTPUT param fields (Optional[T] = None)
|
|
513
|
+
for p in output_params:
|
|
514
|
+
py_type = _resolve_py_type(p.sql_type)
|
|
515
|
+
field_name = sanitize_field_name(p.name, naming_convention) # type: ignore[arg-type]
|
|
516
|
+
type_name = _py_type_name(py_type)
|
|
517
|
+
lines.append(f" {field_name}: Optional[{type_name}] = None")
|
|
518
|
+
|
|
519
|
+
# Store output param names for executor
|
|
520
|
+
if output_param_names:
|
|
521
|
+
lines.append(f" _output_param_names = {repr(output_param_names)}")
|
|
522
|
+
else:
|
|
523
|
+
lines.append(" _output_param_names: list[str] = []")
|
|
524
|
+
|
|
525
|
+
lines.append("")
|
|
526
|
+
if is_single_rs:
|
|
527
|
+
lines.append(" @classmethod")
|
|
528
|
+
lines.append(" def from_db(cls, conn_str: str, **params: Any) -> list[BaseModel]:")
|
|
529
|
+
else:
|
|
530
|
+
lines.append(" @classmethod")
|
|
531
|
+
lines.append(
|
|
532
|
+
" def from_db("
|
|
533
|
+
"cls, conn_str: str, **params: Any"
|
|
534
|
+
") -> list[Any]:"
|
|
535
|
+
)
|
|
536
|
+
lines.append(' """Execute the stored procedure and return typed instances."""')
|
|
537
|
+
if is_single_rs:
|
|
538
|
+
lines.append(" return execute_sp(")
|
|
539
|
+
else:
|
|
540
|
+
lines.append(" return execute_sp_multi(")
|
|
541
|
+
lines.append(" conn_str,")
|
|
542
|
+
lines.append(" cls._sp_name,")
|
|
543
|
+
if not is_single_rs:
|
|
544
|
+
lines.append(" _MODELS,")
|
|
545
|
+
lines.append(" cls,")
|
|
546
|
+
lines.append(" input_params=params,")
|
|
547
|
+
lines.append(f" output_param_names={repr(output_param_names)},")
|
|
548
|
+
lines.append(f" param_order={repr(all_param_names)},")
|
|
549
|
+
lines.append(" )")
|
|
550
|
+
lines.append("")
|
|
551
|
+
return "\n".join(lines) + "\n"
|
singularity/types.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Pydantic v2 models for stored procedure metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Parameter(BaseModel):
|
|
11
|
+
"""Metadata for a single stored procedure parameter."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="forbid")
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
"""Parameter name (e.g. '@OrderId')."""
|
|
17
|
+
|
|
18
|
+
sql_type: str
|
|
19
|
+
"""SQL Server data type (e.g. 'INT', 'NVARCHAR(50)')."""
|
|
20
|
+
|
|
21
|
+
direction: Literal["IN", "OUT", "INOUT"]
|
|
22
|
+
"""Parameter direction: input, output, or bidirectional."""
|
|
23
|
+
|
|
24
|
+
default: str | None = None
|
|
25
|
+
"""Default value expression, or None if no default."""
|
|
26
|
+
|
|
27
|
+
nullable: bool = True
|
|
28
|
+
"""Whether the parameter accepts NULL."""
|
|
29
|
+
|
|
30
|
+
description: str | None = None
|
|
31
|
+
"""Parameter description from sys.extended_properties, or None."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ColumnInfo(BaseModel):
|
|
35
|
+
"""Metadata for a single result set column."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(extra="forbid")
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
"""Column name as returned by SQL Server."""
|
|
41
|
+
|
|
42
|
+
sql_type: str
|
|
43
|
+
"""SQL Server data type (e.g. 'INT', 'VARCHAR(100)')."""
|
|
44
|
+
|
|
45
|
+
nullable: bool = True
|
|
46
|
+
"""Whether the column is nullable."""
|
|
47
|
+
|
|
48
|
+
description: str | None = None
|
|
49
|
+
"""Column description from sys.extended_properties, or None."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SPMetadata(BaseModel):
|
|
53
|
+
"""Complete metadata for a stored procedure's parameters and result sets."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
"""Stored procedure name (schema-qualified where available)."""
|
|
59
|
+
|
|
60
|
+
parameters: list[Parameter] = []
|
|
61
|
+
"""List of parameter metadata objects."""
|
|
62
|
+
|
|
63
|
+
result_sets: list[list[ColumnInfo]] = []
|
|
64
|
+
"""List of result sets, each being a list of ColumnInfo objects."""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def columns(self) -> list[ColumnInfo]:
|
|
68
|
+
"""First result set columns (backward compatibility).
|
|
69
|
+
|
|
70
|
+
Returns the column metadata for the first result set,
|
|
71
|
+
or an empty list if there are no result sets.
|
|
72
|
+
"""
|
|
73
|
+
return self.result_sets[0] if self.result_sets else []
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Server version detection and strategy factory.
|
|
2
|
+
|
|
3
|
+
Parses SQL Server @@VERSION output and selects the appropriate
|
|
4
|
+
introspection strategy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
from singularity.version._azure import AzureIntrospector
|
|
12
|
+
from singularity.version._base import VersionIntrospector
|
|
13
|
+
from singularity.version._legacy import LegacyIntrospector
|
|
14
|
+
from singularity.version._modern import ModernIntrospector
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ServerVersion(Enum):
|
|
18
|
+
"""Supported SQL Server version targets."""
|
|
19
|
+
|
|
20
|
+
MODERN = "modern"
|
|
21
|
+
"""SQL Server 2016 and later."""
|
|
22
|
+
|
|
23
|
+
LEGACY = "legacy"
|
|
24
|
+
"""SQL Server 2008–2014."""
|
|
25
|
+
|
|
26
|
+
AZURE = "azure"
|
|
27
|
+
"""Azure SQL Database."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _select_strategy(version: ServerVersion) -> VersionIntrospector:
|
|
31
|
+
"""Return the introspection strategy for the given server version.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
version: A ServerVersion enum value.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A concrete VersionIntrospector instance.
|
|
38
|
+
"""
|
|
39
|
+
mapping: dict[ServerVersion, type[VersionIntrospector]] = {
|
|
40
|
+
ServerVersion.MODERN: ModernIntrospector,
|
|
41
|
+
ServerVersion.LEGACY: LegacyIntrospector,
|
|
42
|
+
ServerVersion.AZURE: AzureIntrospector,
|
|
43
|
+
}
|
|
44
|
+
cls = mapping[version]
|
|
45
|
+
return cls()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_version_string(version_output: str) -> ServerVersion:
|
|
49
|
+
"""Parse a @@VERSION string and return the matching ServerVersion.
|
|
50
|
+
|
|
51
|
+
Detection logic:
|
|
52
|
+
- Contains 'Azure SQL' → AZURE
|
|
53
|
+
- Contains '2016' or higher year → MODERN
|
|
54
|
+
- Otherwise → LEGACY
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
version_output: The raw output from SELECT @@VERSION.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The detected ServerVersion.
|
|
61
|
+
"""
|
|
62
|
+
upper = version_output.upper()
|
|
63
|
+
|
|
64
|
+
if "AZURE" in upper or "SQL AZURE" in upper:
|
|
65
|
+
return ServerVersion.AZURE
|
|
66
|
+
|
|
67
|
+
# Check for SQL Server 2016 or later by looking for the year
|
|
68
|
+
for year in ("2016", "2017", "2019", "2022"):
|
|
69
|
+
if year in upper:
|
|
70
|
+
return ServerVersion.MODERN
|
|
71
|
+
|
|
72
|
+
return ServerVersion.LEGACY
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"ServerVersion",
|
|
77
|
+
"VersionIntrospector",
|
|
78
|
+
"_select_strategy",
|
|
79
|
+
"parse_version_string",
|
|
80
|
+
]
|