singularitysql 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ ]