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/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Singularity — SQL Server stored procedure introspection and Pydantic model generation."""
|
|
2
|
+
|
|
3
|
+
from singularity.exceptions import SingularityError, SpNotFoundError
|
|
4
|
+
from singularity.introspector import SQLServerIntrospector
|
|
5
|
+
from singularity.model_generator import generate_model
|
|
6
|
+
from singularity.types import ColumnInfo, Parameter, SPMetadata
|
|
7
|
+
from singularity.version import ServerVersion
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SQLServerIntrospector",
|
|
11
|
+
"generate_model",
|
|
12
|
+
"SPMetadata",
|
|
13
|
+
"ColumnInfo",
|
|
14
|
+
"Parameter",
|
|
15
|
+
"ServerVersion",
|
|
16
|
+
"SingularityError",
|
|
17
|
+
"SpNotFoundError",
|
|
18
|
+
]
|
singularity/cli/_app.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Singularity Typer app instance — isolated to avoid circular imports."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _main() -> None:
|
|
9
|
+
"""Entry point with UTF-8 encoding support for Windows consoles."""
|
|
10
|
+
if sys.platform == "win32":
|
|
11
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
12
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
13
|
+
app()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="singularity",
|
|
18
|
+
help="SQL Server stored procedure introspection and Pydantic model generator.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
invoke_without_command=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Register commands — import triggers @app.command() decorators
|
|
24
|
+
import singularity.cli.docs_generate # noqa: E402, F401 — registers docs command
|
|
25
|
+
import singularity.cli.generate # noqa: E402, F401 — registers generate command
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""TOML configuration loading and Pydantic config models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionConfig(BaseModel):
|
|
12
|
+
"""SQL Server connection settings."""
|
|
13
|
+
|
|
14
|
+
server: str
|
|
15
|
+
"""Server hostname or IP address (required)."""
|
|
16
|
+
|
|
17
|
+
database: str
|
|
18
|
+
"""Database name (required)."""
|
|
19
|
+
|
|
20
|
+
driver: str = "ODBC Driver 18 for SQL Server"
|
|
21
|
+
"""ODBC driver name."""
|
|
22
|
+
|
|
23
|
+
trusted_connection: bool = True
|
|
24
|
+
"""Use Windows Authentication when True."""
|
|
25
|
+
|
|
26
|
+
username: str | None = None
|
|
27
|
+
"""SQL Server login username (used when trusted_connection is False)."""
|
|
28
|
+
|
|
29
|
+
password: str | None = None
|
|
30
|
+
"""SQL Server login password (used when trusted_connection is False)."""
|
|
31
|
+
|
|
32
|
+
def build_connection_string(self) -> str:
|
|
33
|
+
"""Build a pyodbc-compatible connection string from the config.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
A DRIVER=SERVER=DATABASE=... connection string.
|
|
37
|
+
"""
|
|
38
|
+
parts = [
|
|
39
|
+
f"DRIVER={{{self.driver}}}",
|
|
40
|
+
f"SERVER={self.server}",
|
|
41
|
+
f"DATABASE={self.database}",
|
|
42
|
+
]
|
|
43
|
+
if self.trusted_connection:
|
|
44
|
+
parts.append("Trusted_Connection=yes")
|
|
45
|
+
else:
|
|
46
|
+
if self.username:
|
|
47
|
+
parts.append(f"UID={self.username}")
|
|
48
|
+
if self.password:
|
|
49
|
+
parts.append(f"PWD={self.password}")
|
|
50
|
+
return ";".join(parts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SpSelectionConfig(BaseModel):
|
|
54
|
+
"""Stored procedure selection criteria."""
|
|
55
|
+
|
|
56
|
+
procedures: list[str] | None = None
|
|
57
|
+
"""Explicit list of stored procedure names to introspect."""
|
|
58
|
+
|
|
59
|
+
pattern: str | None = None
|
|
60
|
+
"""Wildcard pattern (e.g. 'usp_%') to resolve against the database."""
|
|
61
|
+
|
|
62
|
+
@field_validator("procedures", "pattern")
|
|
63
|
+
@classmethod
|
|
64
|
+
def _at_least_one(cls, v: list[str] | str | None) -> list[str] | str | None:
|
|
65
|
+
return v
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class OutputConfig(BaseModel):
|
|
69
|
+
"""Output preferences for generated models."""
|
|
70
|
+
|
|
71
|
+
directory: str = "."
|
|
72
|
+
"""Output directory for generated files."""
|
|
73
|
+
|
|
74
|
+
mode: Literal["dynamic", "source"] = "source"
|
|
75
|
+
"""Generation mode: 'dynamic' for runtime models, 'source' for .py files."""
|
|
76
|
+
|
|
77
|
+
file_naming: str = "{sp_name}.py"
|
|
78
|
+
"""File naming template with {sp_name}, {schema}, {database} variables."""
|
|
79
|
+
|
|
80
|
+
naming_convention: Literal["snake_case", "camelCase", "PascalCase"] = "snake_case"
|
|
81
|
+
"""Field naming convention for generated models."""
|
|
82
|
+
|
|
83
|
+
docs_directory: str | None = None
|
|
84
|
+
"""Output directory for generated Markdown documentation.
|
|
85
|
+
If set, ``singularity docs`` writes docs here. Defaults to ``docs/``."""
|
|
86
|
+
|
|
87
|
+
@field_validator("mode")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _validate_mode(cls, v: str) -> str:
|
|
90
|
+
allowed = {"dynamic", "source"}
|
|
91
|
+
if v not in allowed:
|
|
92
|
+
raise ValueError(f"mode must be one of {allowed}, got '{v}'")
|
|
93
|
+
return v
|
|
94
|
+
|
|
95
|
+
@field_validator("naming_convention")
|
|
96
|
+
@classmethod
|
|
97
|
+
def _validate_convention(cls, v: str) -> str:
|
|
98
|
+
allowed = {"snake_case", "camelCase", "PascalCase"}
|
|
99
|
+
if v not in allowed:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"naming_convention must be one of {allowed}, got '{v}'"
|
|
102
|
+
)
|
|
103
|
+
return v
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SingularityConfig(BaseModel):
|
|
107
|
+
"""Top-level configuration model."""
|
|
108
|
+
|
|
109
|
+
connection: ConnectionConfig
|
|
110
|
+
"""SQL Server connection settings."""
|
|
111
|
+
|
|
112
|
+
sp_selection: SpSelectionConfig
|
|
113
|
+
"""Stored procedure selection criteria."""
|
|
114
|
+
|
|
115
|
+
output: OutputConfig = OutputConfig()
|
|
116
|
+
"""Output preferences (defaults apply if section omitted)."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_config(path: str | Path) -> SingularityConfig:
|
|
120
|
+
"""Load and validate a TOML configuration file.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
path: Path to the TOML config file.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
A validated SingularityConfig instance.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
FileNotFoundError: If the config file does not exist.
|
|
130
|
+
pydantic.ValidationError: If the config is invalid.
|
|
131
|
+
"""
|
|
132
|
+
import tomli
|
|
133
|
+
|
|
134
|
+
path = Path(path)
|
|
135
|
+
|
|
136
|
+
if not path.exists():
|
|
137
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
138
|
+
|
|
139
|
+
raw = path.read_text(encoding="utf-8")
|
|
140
|
+
data = tomli.loads(raw)
|
|
141
|
+
|
|
142
|
+
return SingularityConfig(**data)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""CLI docs command — generates Markdown documentation for stored procedures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from singularity.cli._app import app
|
|
11
|
+
from singularity.cli.config import load_config
|
|
12
|
+
from singularity.introspector import SQLServerIntrospector
|
|
13
|
+
from singularity.model_generator import _generate_source
|
|
14
|
+
from singularity.types import SPMetadata
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def docs(
|
|
21
|
+
config: str = typer.Option(
|
|
22
|
+
...,
|
|
23
|
+
"--config",
|
|
24
|
+
"-c",
|
|
25
|
+
help="Path to TOML configuration file.",
|
|
26
|
+
),
|
|
27
|
+
output: str | None = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--output",
|
|
30
|
+
"-o",
|
|
31
|
+
help="Output directory for documentation. Overrides config.",
|
|
32
|
+
),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Generate Markdown documentation for stored procedures.
|
|
35
|
+
|
|
36
|
+
Introspects the configured stored procedures and writes a Markdown
|
|
37
|
+
file per SP with parameter tables, result set columns, descriptions
|
|
38
|
+
(from ``sys.extended_properties``), and the generated Pydantic model
|
|
39
|
+
source code.
|
|
40
|
+
"""
|
|
41
|
+
# --- Load config -------------------------------------------------------
|
|
42
|
+
cfg_path = Path(config)
|
|
43
|
+
if not cfg_path.exists():
|
|
44
|
+
console.print(f"[red]Error:[/red] Config file not found: [bold]{cfg_path}[/bold]")
|
|
45
|
+
raise typer.Exit(code=1)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
cfg = load_config(str(cfg_path))
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
console.print(f"[red]Error loading config:[/red] {exc}")
|
|
51
|
+
raise typer.Exit(code=1) from exc
|
|
52
|
+
|
|
53
|
+
# --- Resolve output directory ------------------------------------------
|
|
54
|
+
docs_dir: Path
|
|
55
|
+
if output:
|
|
56
|
+
docs_dir = Path(output)
|
|
57
|
+
elif cfg.output.docs_directory:
|
|
58
|
+
docs_dir = Path(cfg.output.docs_directory)
|
|
59
|
+
else:
|
|
60
|
+
docs_dir = Path("docs")
|
|
61
|
+
|
|
62
|
+
docs_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
# --- Connect to SQL Server ---------------------------------------------
|
|
65
|
+
conn_str = cfg.connection.build_connection_string()
|
|
66
|
+
introspector = SQLServerIntrospector(conn_str)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
introspector.connect()
|
|
70
|
+
version = introspector.detect_version()
|
|
71
|
+
console.print(f"[green]Connected.[/green] Detected version: [bold]{version.value}[/bold]")
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
console.print(f"[red]Error connecting to SQL Server:[/red] {exc}")
|
|
74
|
+
raise typer.Exit(code=1) from exc
|
|
75
|
+
|
|
76
|
+
# --- Resolve SPs -------------------------------------------------------
|
|
77
|
+
sp_names: list[str] = []
|
|
78
|
+
|
|
79
|
+
if cfg.sp_selection.procedures:
|
|
80
|
+
sp_names.extend(cfg.sp_selection.procedures)
|
|
81
|
+
|
|
82
|
+
if cfg.sp_selection.pattern:
|
|
83
|
+
try:
|
|
84
|
+
cursor = introspector._connection.cursor() # type: ignore[union-attr]
|
|
85
|
+
cursor.execute(
|
|
86
|
+
"""
|
|
87
|
+
SELECT ROUTINE_NAME
|
|
88
|
+
FROM INFORMATION_SCHEMA.ROUTINES
|
|
89
|
+
WHERE ROUTINE_TYPE = 'PROCEDURE'
|
|
90
|
+
AND ROUTINE_NAME LIKE ?
|
|
91
|
+
""",
|
|
92
|
+
(cfg.sp_selection.pattern,),
|
|
93
|
+
)
|
|
94
|
+
sp_names.extend(row[0] for row in cursor.fetchall())
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
console.print(
|
|
97
|
+
f"[yellow]Error resolving SP pattern '{cfg.sp_selection.pattern}':[/yellow] {exc}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if not sp_names:
|
|
101
|
+
console.print(
|
|
102
|
+
"[yellow]No stored procedures selected.[/yellow] "
|
|
103
|
+
"Check your [bold][sp_selection][/bold] config."
|
|
104
|
+
)
|
|
105
|
+
raise typer.Exit(code=0)
|
|
106
|
+
|
|
107
|
+
# --- Generate docs -----------------------------------------------------
|
|
108
|
+
success_count = 0
|
|
109
|
+
error_count = 0
|
|
110
|
+
|
|
111
|
+
for sp_name in sp_names:
|
|
112
|
+
try:
|
|
113
|
+
meta = introspector.introspect(sp_name)
|
|
114
|
+
doc_content = _render_doc(meta)
|
|
115
|
+
doc_path = docs_dir / f"{sp_name}.md"
|
|
116
|
+
doc_path.write_text(doc_content, encoding="utf-8")
|
|
117
|
+
console.print(f" [green]✓[/green] {sp_name} → {doc_path}")
|
|
118
|
+
success_count += 1
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
console.print(f" [red]✗[/red] {sp_name}: {exc}")
|
|
121
|
+
error_count += 1
|
|
122
|
+
|
|
123
|
+
console.print(
|
|
124
|
+
f"\nDone. [green]{success_count} docs generated[/green], "
|
|
125
|
+
f"[red]{error_count} failed[/red]."
|
|
126
|
+
)
|
|
127
|
+
if error_count > 0:
|
|
128
|
+
raise typer.Exit(code=1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _render_doc(meta: SPMetadata) -> str:
|
|
132
|
+
"""Render a stored procedure's documentation as Markdown.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
meta: Fully introspected SP metadata (includes descriptions).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A Markdown string suitable for writing to a .md file.
|
|
139
|
+
"""
|
|
140
|
+
lines: list[str] = []
|
|
141
|
+
lines.append(f"# {meta.name}")
|
|
142
|
+
lines.append("")
|
|
143
|
+
|
|
144
|
+
# Schema
|
|
145
|
+
schema = meta.name.split(".")[0] if "." in meta.name else "dbo"
|
|
146
|
+
lines.append(f"**Schema:** {schema}")
|
|
147
|
+
lines.append("")
|
|
148
|
+
|
|
149
|
+
# --- Parameters table --------------------------------------------------
|
|
150
|
+
if meta.parameters:
|
|
151
|
+
lines.append("## Parameters")
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.append("| Name | Type | Direction | Default | Nullable | Description |")
|
|
154
|
+
lines.append("|------|------|-----------|---------|----------|-------------|")
|
|
155
|
+
for p in meta.parameters:
|
|
156
|
+
name = p.name
|
|
157
|
+
sql_type = p.sql_type
|
|
158
|
+
direction = p.direction
|
|
159
|
+
default = p.default or "—"
|
|
160
|
+
nullable = "YES" if p.nullable else "NO"
|
|
161
|
+
desc = p.description or "—"
|
|
162
|
+
lines.append(
|
|
163
|
+
f"| {name} | {sql_type} | {direction} | {default} | {nullable} | {desc} |"
|
|
164
|
+
)
|
|
165
|
+
lines.append("")
|
|
166
|
+
|
|
167
|
+
# --- Result set(s) table -----------------------------------------------
|
|
168
|
+
if meta.columns:
|
|
169
|
+
lines.append("## Result Set")
|
|
170
|
+
lines.append("")
|
|
171
|
+
lines.append("| Column | Type | Nullable | Description |")
|
|
172
|
+
lines.append("|--------|------|----------|-------------|")
|
|
173
|
+
for col in meta.columns:
|
|
174
|
+
name = col.name
|
|
175
|
+
sql_type = col.sql_type
|
|
176
|
+
nullable = "YES" if col.nullable else "NO"
|
|
177
|
+
desc = col.description or "—"
|
|
178
|
+
lines.append(f"| {name} | {sql_type} | {nullable} | {desc} |")
|
|
179
|
+
lines.append("")
|
|
180
|
+
|
|
181
|
+
# --- Generated model ---------------------------------------------------
|
|
182
|
+
try:
|
|
183
|
+
source_code = _generate_source(meta)
|
|
184
|
+
lines.append("## Generated Model")
|
|
185
|
+
lines.append("")
|
|
186
|
+
lines.append("```python")
|
|
187
|
+
lines.append(source_code.rstrip("\n"))
|
|
188
|
+
lines.append("```")
|
|
189
|
+
lines.append("")
|
|
190
|
+
except Exception:
|
|
191
|
+
lines.append("## Generated Model")
|
|
192
|
+
lines.append("")
|
|
193
|
+
lines.append("*Model generation failed for this stored procedure.*")
|
|
194
|
+
lines.append("")
|
|
195
|
+
|
|
196
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""CLI generate command — orchestrates the full pipeline.
|
|
2
|
+
|
|
3
|
+
Delegates all introspection and model generation to the library.
|
|
4
|
+
Never reimplements library logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.progress import (
|
|
14
|
+
Progress,
|
|
15
|
+
SpinnerColumn,
|
|
16
|
+
TextColumn,
|
|
17
|
+
)
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from singularity.cli._app import app
|
|
21
|
+
from singularity.cli.config import load_config
|
|
22
|
+
from singularity.introspector import SQLServerIntrospector
|
|
23
|
+
from singularity.model_generator import generate_model
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def generate(
|
|
30
|
+
config: str = typer.Option(
|
|
31
|
+
...,
|
|
32
|
+
"--config",
|
|
33
|
+
"-c",
|
|
34
|
+
help="Path to TOML configuration file.",
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Introspect stored procedures and generate Pydantic models.
|
|
38
|
+
|
|
39
|
+
Reads configuration from a TOML file, connects to SQL Server,
|
|
40
|
+
introspects the specified stored procedures, and writes the
|
|
41
|
+
generated models to the output directory.
|
|
42
|
+
"""
|
|
43
|
+
# --- Load config -------------------------------------------------------
|
|
44
|
+
cfg_path = Path(config)
|
|
45
|
+
if not cfg_path.exists():
|
|
46
|
+
console.print(f"[red]Error:[/red] Config file not found: [bold]{cfg_path}[/bold]")
|
|
47
|
+
raise typer.Exit(code=1)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
cfg = load_config(str(cfg_path))
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
console.print(f"[red]Error loading config:[/red] {exc}")
|
|
53
|
+
raise typer.Exit(code=1) from exc
|
|
54
|
+
|
|
55
|
+
# --- Connect to SQL Server ---------------------------------------------
|
|
56
|
+
conn_str = cfg.connection.build_connection_string()
|
|
57
|
+
introspector = SQLServerIntrospector(conn_str)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with Progress(
|
|
61
|
+
SpinnerColumn(),
|
|
62
|
+
TextColumn("[progress.description]{task.description}"),
|
|
63
|
+
console=console,
|
|
64
|
+
) as progress:
|
|
65
|
+
progress.add_task("Connecting to SQL Server...", total=None)
|
|
66
|
+
introspector.connect()
|
|
67
|
+
version = introspector.detect_version()
|
|
68
|
+
console.print(f"[green]Connected.[/green] Detected version: [bold]{version.value}[/bold]")
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
console.print(f"[red]Error connecting to SQL Server:[/red] {exc}")
|
|
71
|
+
raise typer.Exit(code=1) from exc
|
|
72
|
+
|
|
73
|
+
# --- Resolve SPs to introspect -----------------------------------------
|
|
74
|
+
sp_names: list[str] = []
|
|
75
|
+
|
|
76
|
+
if cfg.sp_selection.procedures:
|
|
77
|
+
sp_names.extend(cfg.sp_selection.procedures)
|
|
78
|
+
|
|
79
|
+
if cfg.sp_selection.pattern:
|
|
80
|
+
try:
|
|
81
|
+
cursor = introspector._connection.cursor() # type: ignore[union-attr]
|
|
82
|
+
cursor.execute(
|
|
83
|
+
"""
|
|
84
|
+
SELECT ROUTINE_NAME
|
|
85
|
+
FROM INFORMATION_SCHEMA.ROUTINES
|
|
86
|
+
WHERE ROUTINE_TYPE = 'PROCEDURE'
|
|
87
|
+
AND ROUTINE_NAME LIKE ?
|
|
88
|
+
""",
|
|
89
|
+
(cfg.sp_selection.pattern,),
|
|
90
|
+
)
|
|
91
|
+
sp_names.extend(row[0] for row in cursor.fetchall())
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
console.print(
|
|
94
|
+
f"[yellow]Error resolving SP pattern '{cfg.sp_selection.pattern}':[/yellow] {exc}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if not sp_names:
|
|
98
|
+
console.print(
|
|
99
|
+
"[yellow]No stored procedures selected.[/yellow] "
|
|
100
|
+
"Check your [bold][sp_selection][/bold] config."
|
|
101
|
+
)
|
|
102
|
+
raise typer.Exit(code=0)
|
|
103
|
+
|
|
104
|
+
# --- Introspect each SP and generate models ----------------------------
|
|
105
|
+
out_dir = Path(cfg.output.directory)
|
|
106
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
|
|
108
|
+
naming_convention = cfg.output.naming_convention
|
|
109
|
+
mode = cfg.output.mode
|
|
110
|
+
file_naming = cfg.output.file_naming
|
|
111
|
+
|
|
112
|
+
summary_data: list[dict[str, str | int]] = []
|
|
113
|
+
|
|
114
|
+
with Progress(
|
|
115
|
+
SpinnerColumn(),
|
|
116
|
+
TextColumn("[progress.description]{task.description}"),
|
|
117
|
+
console=console,
|
|
118
|
+
) as progress:
|
|
119
|
+
task = progress.add_task(
|
|
120
|
+
f"Introspecting [bold]{len(sp_names)}[/bold] stored procedure(s)...",
|
|
121
|
+
total=len(sp_names),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for sp_name in sp_names:
|
|
125
|
+
try:
|
|
126
|
+
meta = introspector.introspect(sp_name)
|
|
127
|
+
|
|
128
|
+
result = generate_model(
|
|
129
|
+
meta,
|
|
130
|
+
mode=mode,
|
|
131
|
+
naming_convention=naming_convention,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if mode == "source":
|
|
135
|
+
parts = sp_name.split(".")
|
|
136
|
+
schema = parts[0] if len(parts) > 1 else "dbo"
|
|
137
|
+
filename = file_naming.format(
|
|
138
|
+
sp_name=sp_name,
|
|
139
|
+
schema=schema,
|
|
140
|
+
database=cfg.connection.database,
|
|
141
|
+
)
|
|
142
|
+
filepath = out_dir / filename
|
|
143
|
+
|
|
144
|
+
if isinstance(result, str):
|
|
145
|
+
filepath.write_text(result, encoding="utf-8")
|
|
146
|
+
else:
|
|
147
|
+
console.print(
|
|
148
|
+
f"[red] {sp_name}:[/red] unexpected type returned"
|
|
149
|
+
)
|
|
150
|
+
summary_data.append({
|
|
151
|
+
"sp_name": sp_name,
|
|
152
|
+
"params": len(meta.parameters),
|
|
153
|
+
"columns": len(meta.columns),
|
|
154
|
+
"status": "ERROR",
|
|
155
|
+
})
|
|
156
|
+
progress.advance(task)
|
|
157
|
+
continue
|
|
158
|
+
else:
|
|
159
|
+
pass # dynamic mode
|
|
160
|
+
|
|
161
|
+
summary_data.append({
|
|
162
|
+
"sp_name": sp_name,
|
|
163
|
+
"params": len(meta.parameters),
|
|
164
|
+
"columns": len(meta.columns),
|
|
165
|
+
"status": "OK",
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
console.print(f"[red] Error processing {sp_name}:[/red] {exc}")
|
|
170
|
+
summary_data.append({
|
|
171
|
+
"sp_name": sp_name,
|
|
172
|
+
"params": 0,
|
|
173
|
+
"columns": 0,
|
|
174
|
+
"status": "ERROR",
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
progress.advance(task)
|
|
178
|
+
|
|
179
|
+
# --- Summary table -----------------------------------------------------
|
|
180
|
+
table = Table(title="Generation Summary")
|
|
181
|
+
table.add_column("SP Name", style="cyan")
|
|
182
|
+
table.add_column("Params", justify="right")
|
|
183
|
+
table.add_column("Columns", justify="right")
|
|
184
|
+
table.add_column("Status")
|
|
185
|
+
|
|
186
|
+
success_count = 0
|
|
187
|
+
error_count = 0
|
|
188
|
+
for row in summary_data:
|
|
189
|
+
status = row["status"]
|
|
190
|
+
if status == "OK":
|
|
191
|
+
status_style = "[green]OK[/green]"
|
|
192
|
+
success_count += 1
|
|
193
|
+
else:
|
|
194
|
+
status_style = "[red]ERROR[/red]"
|
|
195
|
+
error_count += 1
|
|
196
|
+
table.add_row(
|
|
197
|
+
str(row["sp_name"]),
|
|
198
|
+
str(row["params"]),
|
|
199
|
+
str(row["columns"]),
|
|
200
|
+
status_style,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
console.print(table)
|
|
204
|
+
console.print(
|
|
205
|
+
f"\nDone. [green]{success_count} succeeded[/green], "
|
|
206
|
+
f"[red]{error_count} failed[/red]."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if error_count > 0:
|
|
210
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Custom exception hierarchy for Singularity."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SingularityError(Exception):
|
|
5
|
+
"""Base exception for all Singularity errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpNotFoundError(SingularityError):
|
|
9
|
+
"""Raised when a requested stored procedure does not exist."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, sp_name: str) -> None:
|
|
12
|
+
self.sp_name = sp_name
|
|
13
|
+
super().__init__(f"Stored procedure '{sp_name}' not found in the database.")
|