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,122 @@
|
|
|
1
|
+
"""Introspection strategy for Azure SQL Database.
|
|
2
|
+
|
|
3
|
+
Uses sys.parameters for parameter metadata and
|
|
4
|
+
sys.dm_exec_describe_first_result_set for result set column metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from singularity.exceptions import SpNotFoundError
|
|
12
|
+
from singularity.types import ColumnInfo, Parameter, SPMetadata
|
|
13
|
+
from singularity.version._base import VersionIntrospector
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AzureIntrospector(VersionIntrospector):
|
|
17
|
+
"""Strategy for Azure SQL Database: sys.parameters + sys.dm_exec_describe_first_result_set."""
|
|
18
|
+
|
|
19
|
+
def introspect(self, sp_name: str, cursor: Any) -> SPMetadata:
|
|
20
|
+
"""Introspect a stored procedure on Azure SQL Database.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
sp_name: The stored procedure name.
|
|
24
|
+
cursor: An active pyodbc Cursor.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
SPMetadata with parameters and result set columns.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
SpNotFoundError: If the procedure does not exist.
|
|
31
|
+
"""
|
|
32
|
+
simple_name = sp_name.split(".")[-1] if "." in sp_name else sp_name
|
|
33
|
+
|
|
34
|
+
# Verify the procedure exists
|
|
35
|
+
cursor.execute(
|
|
36
|
+
"""
|
|
37
|
+
SELECT COUNT(*)
|
|
38
|
+
FROM sys.objects
|
|
39
|
+
WHERE type = 'P'
|
|
40
|
+
AND name = ?
|
|
41
|
+
""",
|
|
42
|
+
(simple_name,),
|
|
43
|
+
)
|
|
44
|
+
if cursor.fetchone()[0] == 0:
|
|
45
|
+
raise SpNotFoundError(sp_name)
|
|
46
|
+
|
|
47
|
+
# Fetch parameter metadata from sys.parameters
|
|
48
|
+
cursor.execute(
|
|
49
|
+
"""
|
|
50
|
+
SELECT
|
|
51
|
+
p.name,
|
|
52
|
+
tp.name AS data_type,
|
|
53
|
+
CASE
|
|
54
|
+
WHEN p.is_output = 1 THEN 'OUT'
|
|
55
|
+
ELSE 'IN'
|
|
56
|
+
END AS param_mode,
|
|
57
|
+
p.has_default_value,
|
|
58
|
+
p.is_nullable
|
|
59
|
+
FROM sys.parameters p
|
|
60
|
+
JOIN sys.types tp ON p.user_type_id = tp.user_type_id
|
|
61
|
+
JOIN sys.objects o ON p.object_id = o.object_id
|
|
62
|
+
WHERE o.type = 'P'
|
|
63
|
+
AND o.name = ?
|
|
64
|
+
ORDER BY p.parameter_id
|
|
65
|
+
""",
|
|
66
|
+
(simple_name,),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
parameters: list[Parameter] = []
|
|
70
|
+
for row in cursor.fetchall():
|
|
71
|
+
raw_name: str = row[0]
|
|
72
|
+
sql_type: str = row[1]
|
|
73
|
+
direction: str = row[2]
|
|
74
|
+
has_default: bool = row[3] or False
|
|
75
|
+
nullable: bool = row[4] or False
|
|
76
|
+
|
|
77
|
+
parameters.append(
|
|
78
|
+
Parameter(
|
|
79
|
+
name=raw_name,
|
|
80
|
+
sql_type=sql_type.upper(),
|
|
81
|
+
direction=direction, # type: ignore[arg-type]
|
|
82
|
+
default=None if not has_default else "(default)",
|
|
83
|
+
nullable=nullable,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Fetch result sets using Azure's DMV (first result set only)
|
|
88
|
+
result_sets: list[list[ColumnInfo]] = []
|
|
89
|
+
try:
|
|
90
|
+
cursor.execute(
|
|
91
|
+
"""
|
|
92
|
+
SELECT
|
|
93
|
+
name,
|
|
94
|
+
system_type_name,
|
|
95
|
+
is_nullable
|
|
96
|
+
FROM sys.dm_exec_describe_first_result_set(
|
|
97
|
+
N'EXEC ' + QUOTENAME(?), NULL, 0
|
|
98
|
+
)
|
|
99
|
+
""",
|
|
100
|
+
(simple_name,),
|
|
101
|
+
)
|
|
102
|
+
columns = [
|
|
103
|
+
ColumnInfo(
|
|
104
|
+
name=row[0], # name
|
|
105
|
+
sql_type=(row[1] or "UNKNOWN").upper(), # system_type_name
|
|
106
|
+
nullable=bool(row[2]), # is_nullable
|
|
107
|
+
)
|
|
108
|
+
for row in cursor.fetchall()
|
|
109
|
+
if row[0] is not None
|
|
110
|
+
]
|
|
111
|
+
if columns:
|
|
112
|
+
result_sets.append(columns)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# Enrich with column descriptions from extended properties
|
|
117
|
+
for rs in result_sets:
|
|
118
|
+
descriptions = self._fetch_column_descriptions(sp_name, cursor, rs)
|
|
119
|
+
for col in rs:
|
|
120
|
+
col.description = descriptions.get(col.name)
|
|
121
|
+
|
|
122
|
+
return SPMetadata(name=sp_name, parameters=parameters, result_sets=result_sets)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Abstract base class for version-specific introspection strategies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from singularity.types import SPMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VersionIntrospector(ABC):
|
|
12
|
+
"""Strategy for introspecting stored procedure metadata for a specific
|
|
13
|
+
SQL Server version or platform.
|
|
14
|
+
|
|
15
|
+
Each concrete implementation knows the correct system views, functions,
|
|
16
|
+
and DMVs to query for parameter and result set metadata.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def introspect(self, sp_name: str, cursor: Any) -> SPMetadata:
|
|
21
|
+
"""Introspect a stored procedure and return its metadata.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
sp_name: The stored procedure name (with or without schema).
|
|
25
|
+
cursor: An active pyodbc Cursor connected to the database.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
SPMetadata containing parameters and result set column info.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
SpNotFoundError: If the stored procedure does not exist.
|
|
32
|
+
"""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def _fetch_column_descriptions(
|
|
36
|
+
self, sp_name: str, cursor: Any, columns: list[Any],
|
|
37
|
+
) -> dict[str, str]:
|
|
38
|
+
"""Query sys.extended_properties for column descriptions.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
sp_name: The stored procedure name (schema-qualified if available).
|
|
42
|
+
cursor: An active pyodbc Cursor.
|
|
43
|
+
columns: List of ColumnInfo objects to look up descriptions for.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A dict mapping column names to their descriptions.
|
|
47
|
+
"""
|
|
48
|
+
if not columns:
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
descriptions: dict[str, str] = {}
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
for col in columns:
|
|
55
|
+
cursor.execute(
|
|
56
|
+
"""
|
|
57
|
+
SELECT CAST(ep.value AS NVARCHAR(MAX))
|
|
58
|
+
FROM sys.extended_properties ep
|
|
59
|
+
WHERE ep.class = 1
|
|
60
|
+
AND ep.major_id = OBJECT_ID(?)
|
|
61
|
+
AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(?), ?, 'ColumnId')
|
|
62
|
+
AND ep.name = 'MS_Description'
|
|
63
|
+
""",
|
|
64
|
+
(sp_name, sp_name, col.name),
|
|
65
|
+
)
|
|
66
|
+
row = cursor.fetchone()
|
|
67
|
+
if row and row[0] is not None:
|
|
68
|
+
descriptions[col.name] = str(row[0])
|
|
69
|
+
except Exception:
|
|
70
|
+
# Extended properties are optional — silently skip on failure
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return descriptions
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Introspection strategy for SQL Server 2008–2014.
|
|
2
|
+
|
|
3
|
+
Uses sys.parameters for parameter metadata and falls back to
|
|
4
|
+
sys.columns + sys.objects for result set columns when
|
|
5
|
+
sp_describe_first_result_set is unavailable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from singularity.exceptions import SpNotFoundError
|
|
13
|
+
from singularity.types import ColumnInfo, Parameter, SPMetadata
|
|
14
|
+
from singularity.version._base import VersionIntrospector
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LegacyIntrospector(VersionIntrospector):
|
|
18
|
+
"""Strategy for SQL Server 2008–2014 using sys.parameters + sys.columns/sys.objects fallback."""
|
|
19
|
+
|
|
20
|
+
def introspect(self, sp_name: str, cursor: Any) -> SPMetadata:
|
|
21
|
+
"""Introspect a stored procedure using legacy SQL Server system views.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
sp_name: The stored procedure name.
|
|
25
|
+
cursor: An active pyodbc Cursor.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
SPMetadata with parameters and result set columns.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
SpNotFoundError: If the procedure does not exist.
|
|
32
|
+
"""
|
|
33
|
+
simple_name = sp_name.split(".")[-1] if "." in sp_name else sp_name
|
|
34
|
+
|
|
35
|
+
# Verify the procedure exists via sys.objects
|
|
36
|
+
cursor.execute(
|
|
37
|
+
"""
|
|
38
|
+
SELECT COUNT(*)
|
|
39
|
+
FROM sys.objects
|
|
40
|
+
WHERE type = 'P'
|
|
41
|
+
AND name = ?
|
|
42
|
+
""",
|
|
43
|
+
(simple_name,),
|
|
44
|
+
)
|
|
45
|
+
if cursor.fetchone()[0] == 0:
|
|
46
|
+
raise SpNotFoundError(sp_name)
|
|
47
|
+
|
|
48
|
+
# Fetch parameter metadata from sys.parameters
|
|
49
|
+
cursor.execute(
|
|
50
|
+
"""
|
|
51
|
+
SELECT
|
|
52
|
+
p.name,
|
|
53
|
+
tp.name AS data_type,
|
|
54
|
+
CASE
|
|
55
|
+
WHEN p.is_output = 1 THEN 'OUT'
|
|
56
|
+
ELSE 'IN'
|
|
57
|
+
END AS param_mode,
|
|
58
|
+
p.has_default_value,
|
|
59
|
+
p.is_nullable
|
|
60
|
+
FROM sys.parameters p
|
|
61
|
+
JOIN sys.types tp ON p.user_type_id = tp.user_type_id
|
|
62
|
+
JOIN sys.objects o ON p.object_id = o.object_id
|
|
63
|
+
WHERE o.type = 'P'
|
|
64
|
+
AND o.name = ?
|
|
65
|
+
ORDER BY p.parameter_id
|
|
66
|
+
""",
|
|
67
|
+
(simple_name,),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parameters: list[Parameter] = []
|
|
71
|
+
for row in cursor.fetchall():
|
|
72
|
+
raw_name: str = row[0] # e.g. "@OrderId"
|
|
73
|
+
sql_type: str = row[1]
|
|
74
|
+
direction: str = row[2]
|
|
75
|
+
has_default: bool = row[3] or False
|
|
76
|
+
nullable: bool = row[4] or False
|
|
77
|
+
|
|
78
|
+
parameters.append(
|
|
79
|
+
Parameter(
|
|
80
|
+
name=raw_name,
|
|
81
|
+
sql_type=sql_type.upper(),
|
|
82
|
+
direction=direction, # type: ignore[arg-type]
|
|
83
|
+
default=None if not has_default else "(default)",
|
|
84
|
+
nullable=nullable,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# First result set: try sp_describe_first_result_set;
|
|
89
|
+
# fall back to sys.columns + sys.objects join.
|
|
90
|
+
result_sets: list[list[ColumnInfo]] = []
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
tsql = f"EXEC {sp_name}"
|
|
94
|
+
for idx in range(10):
|
|
95
|
+
cursor.execute(
|
|
96
|
+
"{CALL sp_describe_first_result_set(?, NULL, ?)}",
|
|
97
|
+
(tsql, idx),
|
|
98
|
+
)
|
|
99
|
+
rows = cursor.fetchall()
|
|
100
|
+
columns = [
|
|
101
|
+
ColumnInfo(
|
|
102
|
+
name=row[2], # name (index 2)
|
|
103
|
+
sql_type=(row[5] or "UNKNOWN").upper(), # system_type_name (index 5)
|
|
104
|
+
nullable=bool(row[3]), # is_nullable (index 3)
|
|
105
|
+
)
|
|
106
|
+
for row in rows
|
|
107
|
+
if row[2] is not None
|
|
108
|
+
]
|
|
109
|
+
if not columns:
|
|
110
|
+
break
|
|
111
|
+
result_sets.append(columns)
|
|
112
|
+
except Exception:
|
|
113
|
+
# Fallback: query sys.columns through sys.objects for the SP
|
|
114
|
+
try:
|
|
115
|
+
cursor.execute(
|
|
116
|
+
"""
|
|
117
|
+
SELECT
|
|
118
|
+
c.name,
|
|
119
|
+
tp.name AS data_type,
|
|
120
|
+
c.is_nullable
|
|
121
|
+
FROM sys.columns c
|
|
122
|
+
JOIN sys.objects o ON c.object_id = o.object_id
|
|
123
|
+
JOIN sys.types tp ON c.user_type_id = tp.user_type_id
|
|
124
|
+
WHERE o.type = 'P'
|
|
125
|
+
AND o.name = ?
|
|
126
|
+
ORDER BY c.column_id
|
|
127
|
+
""",
|
|
128
|
+
(simple_name,),
|
|
129
|
+
)
|
|
130
|
+
columns = [
|
|
131
|
+
ColumnInfo(
|
|
132
|
+
name=row[0],
|
|
133
|
+
sql_type=row[1].upper(),
|
|
134
|
+
nullable=row[2] or False,
|
|
135
|
+
)
|
|
136
|
+
for row in cursor.fetchall()
|
|
137
|
+
]
|
|
138
|
+
if columns:
|
|
139
|
+
result_sets.append(columns)
|
|
140
|
+
except Exception:
|
|
141
|
+
# If everything fails, return empty result sets gracefully
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
# Enrich with column descriptions from extended properties
|
|
145
|
+
for rs in result_sets:
|
|
146
|
+
descriptions = self._fetch_column_descriptions(sp_name, cursor, rs)
|
|
147
|
+
for col in rs:
|
|
148
|
+
col.description = descriptions.get(col.name)
|
|
149
|
+
|
|
150
|
+
return SPMetadata(name=sp_name, parameters=parameters, result_sets=result_sets)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Introspection strategy for SQL Server 2016 and later.
|
|
2
|
+
|
|
3
|
+
Uses INFORMATION_SCHEMA.PARAMETERS for parameter metadata and
|
|
4
|
+
sp_describe_first_result_set for result set column metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from singularity.exceptions import SpNotFoundError
|
|
12
|
+
from singularity.types import ColumnInfo, Parameter, SPMetadata
|
|
13
|
+
from singularity.version._base import VersionIntrospector
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModernIntrospector(VersionIntrospector):
|
|
17
|
+
"""Strategy for SQL Server 2016+ using INFORMATION_SCHEMA + sp_describe_first_result_set."""
|
|
18
|
+
|
|
19
|
+
def introspect(self, sp_name: str, cursor: Any) -> SPMetadata:
|
|
20
|
+
"""Introspect a stored procedure using modern SQL Server system views.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
sp_name: The stored procedure name.
|
|
24
|
+
cursor: An active pyodbc Cursor.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
SPMetadata with parameters and result set columns.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
SpNotFoundError: If the procedure does not exist.
|
|
31
|
+
"""
|
|
32
|
+
# Strip schema prefix for lookup if present
|
|
33
|
+
simple_name = sp_name.split(".")[-1] if "." in sp_name else sp_name
|
|
34
|
+
|
|
35
|
+
# Verify the procedure exists
|
|
36
|
+
cursor.execute(
|
|
37
|
+
"""
|
|
38
|
+
SELECT COUNT(*)
|
|
39
|
+
FROM INFORMATION_SCHEMA.ROUTINES
|
|
40
|
+
WHERE ROUTINE_TYPE = 'PROCEDURE'
|
|
41
|
+
AND ROUTINE_NAME = ?
|
|
42
|
+
""",
|
|
43
|
+
(simple_name,),
|
|
44
|
+
)
|
|
45
|
+
if cursor.fetchone()[0] == 0:
|
|
46
|
+
raise SpNotFoundError(sp_name)
|
|
47
|
+
|
|
48
|
+
# Fetch parameter metadata from sys.parameters
|
|
49
|
+
cursor.execute(
|
|
50
|
+
"""
|
|
51
|
+
SELECT
|
|
52
|
+
p.name,
|
|
53
|
+
tp.name AS data_type,
|
|
54
|
+
CASE
|
|
55
|
+
WHEN p.is_output = 1 THEN 'OUT'
|
|
56
|
+
WHEN p.is_output = 0 AND p.is_readonly = 0 THEN 'IN'
|
|
57
|
+
ELSE 'INOUT'
|
|
58
|
+
END AS param_mode,
|
|
59
|
+
p.has_default_value,
|
|
60
|
+
p.is_nullable
|
|
61
|
+
FROM sys.parameters p
|
|
62
|
+
JOIN sys.types tp ON p.user_type_id = tp.user_type_id
|
|
63
|
+
JOIN sys.objects o ON p.object_id = o.object_id
|
|
64
|
+
WHERE o.type = 'P'
|
|
65
|
+
AND o.name = ?
|
|
66
|
+
ORDER BY p.parameter_id
|
|
67
|
+
""",
|
|
68
|
+
(simple_name,),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
parameters: list[Parameter] = []
|
|
72
|
+
for row in cursor.fetchall():
|
|
73
|
+
raw_name: str = row[0] # e.g. "@OrderId"
|
|
74
|
+
sql_type: str = row[1]
|
|
75
|
+
direction: str = row[2]
|
|
76
|
+
has_default: bool = row[3] or False
|
|
77
|
+
nullable: bool = row[4] or False
|
|
78
|
+
|
|
79
|
+
parameters.append(
|
|
80
|
+
Parameter(
|
|
81
|
+
name=raw_name,
|
|
82
|
+
sql_type=sql_type.upper(),
|
|
83
|
+
direction=direction, # type: ignore[arg-type]
|
|
84
|
+
default=None if not has_default else "(default)",
|
|
85
|
+
nullable=nullable,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Fetch all result sets via sp_describe_first_result_set
|
|
90
|
+
result_sets: list[list[ColumnInfo]] = []
|
|
91
|
+
try:
|
|
92
|
+
tsql = f"EXEC {sp_name}"
|
|
93
|
+
for idx in range(10):
|
|
94
|
+
cursor.execute(
|
|
95
|
+
"{CALL sp_describe_first_result_set(?, NULL, ?)}",
|
|
96
|
+
(tsql, idx),
|
|
97
|
+
)
|
|
98
|
+
rows = cursor.fetchall()
|
|
99
|
+
columns = [
|
|
100
|
+
ColumnInfo(
|
|
101
|
+
name=row[2], # name (index 2)
|
|
102
|
+
sql_type=(row[5] or "UNKNOWN").upper(), # system_type_name (index 5)
|
|
103
|
+
nullable=bool(row[3]), # is_nullable (index 3)
|
|
104
|
+
)
|
|
105
|
+
for row in rows
|
|
106
|
+
if row[2] is not None # skip unnamed columns
|
|
107
|
+
]
|
|
108
|
+
if not columns:
|
|
109
|
+
break
|
|
110
|
+
result_sets.append(columns)
|
|
111
|
+
except Exception:
|
|
112
|
+
# If sp_describe_first_result_set fails (e.g. dynamic SQL),
|
|
113
|
+
# return empty result sets rather than crashing.
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
# Enrich with column descriptions from extended properties
|
|
117
|
+
for rs in result_sets:
|
|
118
|
+
descriptions = self._fetch_column_descriptions(sp_name, cursor, rs)
|
|
119
|
+
for col in rs:
|
|
120
|
+
col.description = descriptions.get(col.name)
|
|
121
|
+
|
|
122
|
+
return SPMetadata(name=sp_name, parameters=parameters, result_sets=result_sets)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: singularitysql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SQL Server stored procedure introspection and Pydantic model generator
|
|
5
|
+
Author: Samuel Urrego
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyodbc>=5.0
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Requires-Dist: typer>=0.9
|
|
13
|
+
Requires-Dist: tomli>=2.0
|
|
14
|
+
Requires-Dist: rich>=15.0.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-mock>=3.14; extra == "dev"
|
|
19
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<img src="assets/logo.png" alt="Singularity" width="400">
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
> SQL Server → Pydantic v2. Automatically.
|
|
28
|
+
|
|
29
|
+
[](https://github.com/Samuel-Urrego/Singularity/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/singularity/)
|
|
31
|
+
[](https://github.com/astral-sh/ruff)
|
|
32
|
+
|
|
33
|
+
Singularity bridges the gap between SQL Server and Python. Connect to your database, point at a stored procedure, and get a **Pydantic v2 model** — as a runtime class or a `.py` file you can commit.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
singularity --config config.toml
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from singularity import SQLServerIntrospector, generate_model
|
|
41
|
+
|
|
42
|
+
introspector = SQLServerIntrospector("DRIVER={ODBC Driver 17};SERVER=...;DATABASE=...")
|
|
43
|
+
meta = introspector.introspect("usp_GetOrders")
|
|
44
|
+
model = generate_model(meta, mode="dynamic")
|
|
45
|
+
# <class 'pydantic.main.UspGetOrders'>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Why Singularity?
|
|
49
|
+
|
|
50
|
+
**The problem**: You have dozens of complex stored procedures in SQL Server. Calling them from Python means manually writing Pydantic models for every parameter and result set. One typo, and you get a runtime error.
|
|
51
|
+
|
|
52
|
+
**The solution**: Singularity reads SQL Server's system catalog (`sys.parameters`, `sp_describe_first_result_set`) and generates the models for you. Zero manual mapping.
|
|
53
|
+
|
|
54
|
+
| Approach | Lines of code | Maintainable | Type-safe |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| Manual Pydantic models | 100s–1000s | ❌ | ⚠️ (manual) |
|
|
57
|
+
| Raw dicts / tuples | Fewer | ❌ | ❌ |
|
|
58
|
+
| **Singularity** | **Zero** | ✅ | ✅ |
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- 🔌 **Auto-connect** — pyodbc connection with `@@VERSION` detection
|
|
63
|
+
- 🧠 **Version-aware** — Modern (2016+), Legacy (2008–2014), and Azure SQL strategies
|
|
64
|
+
- 📦 **Two output modes**:
|
|
65
|
+
- `"dynamic"` — `create_model()` at runtime, usable immediately
|
|
66
|
+
- `"source"` — `.py` files you can commit and review
|
|
67
|
+
- 🏷️ **Full type mapping** — `INT`→`int`, `VARCHAR`→`str`, `DATETIME`→`datetime`, `BIT`→`bool`, etc.
|
|
68
|
+
- 🎨 **Configurable naming** — snake_case, camelCase, PascalCase for field names
|
|
69
|
+
- 🗂️ **File naming templates** — `{schema}_{sp_name}.py`, `{database}_{sp_name}.py`, etc.
|
|
70
|
+
- 🛡️ **Nullable awareness** — `Optional[T]` for nullable columns
|
|
71
|
+
- ⚡ **UV-first** — fast dependency management
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv add singularity
|
|
77
|
+
# or
|
|
78
|
+
pip install singularity
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Prerequisite**: [ODBC Driver for SQL Server](https://learn.microsoft.com/en-us/sql/connect/odbc/download-odbc-driver-for-sql-server) (17 or 18).
|
|
82
|
+
|
|
83
|
+
## Quick Start
|
|
84
|
+
|
|
85
|
+
### 1. Create a config file
|
|
86
|
+
|
|
87
|
+
```toml
|
|
88
|
+
# config.toml
|
|
89
|
+
[connection]
|
|
90
|
+
server = "localhost"
|
|
91
|
+
database = "AdventureWorks"
|
|
92
|
+
driver = "ODBC Driver 18 for SQL Server"
|
|
93
|
+
trusted_connection = true
|
|
94
|
+
|
|
95
|
+
[sp_selection]
|
|
96
|
+
pattern = "usp_%"
|
|
97
|
+
|
|
98
|
+
[output]
|
|
99
|
+
directory = "generated_models"
|
|
100
|
+
mode = "source"
|
|
101
|
+
file_naming = "{schema}_{sp_name}.py"
|
|
102
|
+
naming_convention = "snake_case"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 2. Generate models
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
singularity --config config.toml
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
Connected. Detected version: modern
|
|
113
|
+
Introspecting usp_GetOrders... → generated_models/dbo_usp_GetOrders.py
|
|
114
|
+
Introspecting usp_GetCustomers... → generated_models/dbo_usp_GetCustomers.py
|
|
115
|
+
|
|
116
|
+
Done. 2 succeeded, 0 failed.
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. Use the generated models
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from generated_models.dbo_usp_GetOrders import UspGetOrders
|
|
123
|
+
|
|
124
|
+
order = UspGetOrders(id=1, customer_name="Acme Corp", total=99.99)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Library Usage
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from singularity import SQLServerIntrospector, generate_model
|
|
131
|
+
|
|
132
|
+
# Connect and introspect
|
|
133
|
+
introspector = SQLServerIntrospector(conn_str)
|
|
134
|
+
introspector.connect()
|
|
135
|
+
version = introspector.detect_version() # ServerVersion.MODERN
|
|
136
|
+
metadata = introspector.introspect("usp_GetOrders")
|
|
137
|
+
|
|
138
|
+
# Runtime model
|
|
139
|
+
DynamicModel = generate_model(metadata, mode="dynamic")
|
|
140
|
+
instance = DynamicModel(id=1, customer_name="Acme")
|
|
141
|
+
|
|
142
|
+
# Source code string
|
|
143
|
+
source_code = generate_model(metadata, mode="source")
|
|
144
|
+
with open("models/usp_GetOrders.py", "w") as f:
|
|
145
|
+
f.write(source_code)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Configuration Reference
|
|
149
|
+
|
|
150
|
+
### `[connection]`
|
|
151
|
+
|
|
152
|
+
| Field | Required | Default | Description |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| `server` | ✅ | — | Server hostname or IP |
|
|
155
|
+
| `database` | ✅ | — | Database name |
|
|
156
|
+
| `driver` | ❌ | `ODBC Driver 18 for SQL Server` | ODBC driver name |
|
|
157
|
+
| `trusted_connection` | ❌ | `true` | Use Windows auth |
|
|
158
|
+
| `username` | ❌ | — | SQL auth username |
|
|
159
|
+
| `password` | ❌ | — | SQL auth password |
|
|
160
|
+
|
|
161
|
+
### `[sp_selection]`
|
|
162
|
+
|
|
163
|
+
| Field | Required | Description |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `procedures` | ❌ | Explicit list of SP names |
|
|
166
|
+
| `pattern` | ❌ | Wildcard pattern (e.g. `usp_%`) |
|
|
167
|
+
|
|
168
|
+
At least one of `procedures` or `pattern` must be specified.
|
|
169
|
+
|
|
170
|
+
### `[output]`
|
|
171
|
+
|
|
172
|
+
| Field | Required | Default | Description |
|
|
173
|
+
|---|---|---|---|
|
|
174
|
+
| `directory` | ❌ | `.` | Output directory |
|
|
175
|
+
| `mode` | ❌ | `source` | `source` or `dynamic` |
|
|
176
|
+
| `file_naming` | ❌ | `{sp_name}.py` | Template with `{schema}`, `{database}`, `{sp_name}` |
|
|
177
|
+
| `naming_convention` | ❌ | `snake_case` | `snake_case`, `camelCase`, or `PascalCase` |
|
|
178
|
+
|
|
179
|
+
## Supported SQL Server Versions
|
|
180
|
+
|
|
181
|
+
| Version | Strategy | Parameter introspection | Result set metadata |
|
|
182
|
+
|---|---|---|---|
|
|
183
|
+
| 2016+ | Modern | `sys.parameters` | `sp_describe_first_result_set` |
|
|
184
|
+
| 2008–2014 | Legacy | `sys.parameters` | `sp_describe_first_result_set` + `sys.columns` fallback |
|
|
185
|
+
| Azure SQL | Azure | `sys.parameters` | `sys.dm_exec_describe_first_result_set` |
|
|
186
|
+
|
|
187
|
+
## Type Mapping
|
|
188
|
+
|
|
189
|
+
| SQL Server | Python | Pydantic |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| `INT`, `BIGINT`, `SMALLINT`, `TINYINT` | `int` | `int` |
|
|
192
|
+
| `VARCHAR`, `NVARCHAR`, `CHAR`, `NCHAR`, `TEXT` | `str` | `str` |
|
|
193
|
+
| `DATETIME`, `DATETIME2`, `DATE`, `SMALLDATETIME` | `datetime` | `datetime` |
|
|
194
|
+
| `BIT` | `bool` | `bool` |
|
|
195
|
+
| `DECIMAL`, `NUMERIC`, `FLOAT`, `REAL`, `MONEY` | `float` | `float` |
|
|
196
|
+
| `UNIQUEIDENTIFIER` | `str` | `str` |
|
|
197
|
+
| Unknown types | `str` + warning | `str` |
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
# Clone and install
|
|
203
|
+
git clone https://github.com/Samuel-Urrego/Singularity
|
|
204
|
+
cd singularity
|
|
205
|
+
uv sync
|
|
206
|
+
|
|
207
|
+
# Run tests
|
|
208
|
+
uv run pytest
|
|
209
|
+
|
|
210
|
+
# Lint and type-check
|
|
211
|
+
uv run ruff check .
|
|
212
|
+
uv run mypy singularity/
|
|
213
|
+
|
|
214
|
+
# Install pre-commit hooks
|
|
215
|
+
uv run pre-commit install
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT — see [LICENSE](LICENSE) for details.
|