sprocguard 0.1.1__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.
- sprocguard/__init__.py +3 -0
- sprocguard/analysis.py +485 -0
- sprocguard/capture.py +331 -0
- sprocguard/cli.py +132 -0
- sprocguard/compare.py +386 -0
- sprocguard/config.py +144 -0
- sprocguard/db.py +148 -0
- sprocguard/server.py +107 -0
- sprocguard/service.py +77 -0
- sprocguard/tsqlt.py +180 -0
- sprocguard-0.1.1.dist-info/METADATA +230 -0
- sprocguard-0.1.1.dist-info/RECORD +15 -0
- sprocguard-0.1.1.dist-info/WHEEL +4 -0
- sprocguard-0.1.1.dist-info/entry_points.txt +2 -0
- sprocguard-0.1.1.dist-info/licenses/LICENSE +21 -0
sprocguard/__init__.py
ADDED
sprocguard/analysis.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""Inspect a stored procedure: parameters, tables it reads and writes, and risks.
|
|
2
|
+
|
|
3
|
+
The inspection combines SQL Server metadata (``sys.dm_sql_referenced_entities``)
|
|
4
|
+
with a light static analysis of the procedure text. Neither is perfect on its
|
|
5
|
+
own, so sprocguard uses both and tells the user where the picture may be
|
|
6
|
+
incomplete (dynamic SQL, for example).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .db import Database
|
|
17
|
+
|
|
18
|
+
# --------------------------------------------------------------------------------------
|
|
19
|
+
# Names and types
|
|
20
|
+
# --------------------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def quote_name(*parts: str) -> str:
|
|
24
|
+
"""Return a safely bracketed, dot-separated SQL Server name."""
|
|
25
|
+
return ".".join("[" + part.replace("]", "]]") + "]" for part in parts)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def type_decl(type_name: str, max_length: int, precision: int, scale: int) -> str:
|
|
29
|
+
"""Build a T-SQL type declaration (e.g. ``nvarchar(50)``) from catalog values."""
|
|
30
|
+
t = type_name.lower()
|
|
31
|
+
if t in ("varchar", "char", "varbinary", "binary"):
|
|
32
|
+
return f"{t}(max)" if max_length == -1 else f"{t}({max_length})"
|
|
33
|
+
if t in ("nvarchar", "nchar"):
|
|
34
|
+
return f"{t}(max)" if max_length == -1 else f"{t}({max_length // 2})"
|
|
35
|
+
if t in ("decimal", "numeric"):
|
|
36
|
+
return f"{t}({precision},{scale})"
|
|
37
|
+
if t in ("datetime2", "time", "datetimeoffset"):
|
|
38
|
+
return f"{t}({scale})"
|
|
39
|
+
return t
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --------------------------------------------------------------------------------------
|
|
43
|
+
# Static analysis
|
|
44
|
+
# --------------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def strip_comments_and_strings(sql: str) -> str:
|
|
48
|
+
"""Remove comments and blank out string literals so patterns don't match inside them."""
|
|
49
|
+
out: list[str] = []
|
|
50
|
+
i, n = 0, len(sql)
|
|
51
|
+
while i < n:
|
|
52
|
+
ch = sql[i]
|
|
53
|
+
nxt = sql[i + 1] if i + 1 < n else ""
|
|
54
|
+
if ch == "-" and nxt == "-":
|
|
55
|
+
end = sql.find("\n", i)
|
|
56
|
+
i = n if end == -1 else end
|
|
57
|
+
out.append(" ")
|
|
58
|
+
elif ch == "/" and nxt == "*":
|
|
59
|
+
depth, i = 1, i + 2
|
|
60
|
+
while i < n and depth:
|
|
61
|
+
if sql.startswith("/*", i):
|
|
62
|
+
depth, i = depth + 1, i + 2
|
|
63
|
+
elif sql.startswith("*/", i):
|
|
64
|
+
depth, i = depth - 1, i + 2
|
|
65
|
+
else:
|
|
66
|
+
i += 1
|
|
67
|
+
out.append(" ")
|
|
68
|
+
elif ch == "'":
|
|
69
|
+
i += 1
|
|
70
|
+
while i < n:
|
|
71
|
+
if sql[i] == "'" and i + 1 < n and sql[i + 1] == "'":
|
|
72
|
+
i += 2
|
|
73
|
+
elif sql[i] == "'":
|
|
74
|
+
i += 1
|
|
75
|
+
break
|
|
76
|
+
else:
|
|
77
|
+
i += 1
|
|
78
|
+
out.append("''")
|
|
79
|
+
else:
|
|
80
|
+
out.append(ch)
|
|
81
|
+
i += 1
|
|
82
|
+
return "".join(out)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Risk:
|
|
87
|
+
code: str
|
|
88
|
+
severity: str # "block" | "warn" | "info"
|
|
89
|
+
message: str
|
|
90
|
+
evidence: list[str] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_RISK_RULES: list[tuple[str, str, str, str]] = [
|
|
94
|
+
# (code, severity, regex, message)
|
|
95
|
+
(
|
|
96
|
+
"external_side_effect",
|
|
97
|
+
"block",
|
|
98
|
+
r"\b(xp_cmdshell|sp_send_dbmail|sp_OA\w+|sp_start_job|OPENQUERY|OPENROWSET|OPENDATASOURCE|BULK\s+INSERT)\b"
|
|
99
|
+
r"|\bEXEC(UTE)?\s*\(.*?\)\s*AT\s+\w+",
|
|
100
|
+
"The procedure can affect things outside the database (e-mail, files, other servers, jobs). "
|
|
101
|
+
"A rollback cannot undo that, so sprocguard will not run it unless "
|
|
102
|
+
"[safety].allow_external_side_effects = true.",
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
"linked_server",
|
|
106
|
+
"block",
|
|
107
|
+
r"(?<![\w\].])(\[?\w+\]?\.){3}\[?\w+\]?(?![\w.])",
|
|
108
|
+
"Four-part names point to a linked server. Changes there may not be rolled back.",
|
|
109
|
+
),
|
|
110
|
+
(
|
|
111
|
+
"dynamic_sql",
|
|
112
|
+
"warn",
|
|
113
|
+
r"\bsp_executesql\b|\bEXEC(UTE)?\s*\(\s*(@|N?'')",
|
|
114
|
+
"Dynamic SQL: sprocguard cannot see which tables it writes. Data changes are still rolled back, "
|
|
115
|
+
"but changes to tables it did not detect will not be compared. Use watch_tables to add them.",
|
|
116
|
+
),
|
|
117
|
+
(
|
|
118
|
+
"inner_rollback",
|
|
119
|
+
"warn",
|
|
120
|
+
r"\bROLLBACK(\s+(TRAN|TRANSACTION|WORK))?\b",
|
|
121
|
+
"The procedure rolls back on its own. sprocguard still undoes all changes, but SQL Server may "
|
|
122
|
+
"report error 266 (transaction count mismatch) during capture.",
|
|
123
|
+
),
|
|
124
|
+
(
|
|
125
|
+
"waitfor",
|
|
126
|
+
"warn",
|
|
127
|
+
r"\bWAITFOR\b",
|
|
128
|
+
"WAITFOR makes captures slow and can hold locks.",
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
"ddl",
|
|
132
|
+
"warn",
|
|
133
|
+
r"\b(CREATE|ALTER|DROP)\s+(TABLE|INDEX|VIEW|PROC|PROCEDURE|FUNCTION|TRIGGER|SCHEMA)\s+(?!#)",
|
|
134
|
+
"The procedure changes database structure (DDL). It is rolled back, but behavior may depend on it.",
|
|
135
|
+
),
|
|
136
|
+
(
|
|
137
|
+
"nondeterministic",
|
|
138
|
+
"info",
|
|
139
|
+
r"\b(GETDATE|GETUTCDATE|SYSDATETIME|SYSUTCDATETIME|SYSDATETIMEOFFSET|NEWID|NEWSEQUENTIALID|RAND|CRYPT_GEN_RANDOM)\s*\("
|
|
140
|
+
r"|\bCURRENT_TIMESTAMP\b",
|
|
141
|
+
"Uses values that change on every run (dates, random values). sprocguard runs each case twice and "
|
|
142
|
+
"ignores the columns that change by themselves.",
|
|
143
|
+
),
|
|
144
|
+
(
|
|
145
|
+
"identity",
|
|
146
|
+
"info",
|
|
147
|
+
r"\bSCOPE_IDENTITY\s*\(|@@IDENTITY\b|\bIDENT_CURRENT\s*\(|\bOUTPUT\s+inserted\.",
|
|
148
|
+
"Returns generated IDs. IDENTITY values are not reused after a rollback, so they are treated "
|
|
149
|
+
"as volatile.",
|
|
150
|
+
),
|
|
151
|
+
(
|
|
152
|
+
"error_paths",
|
|
153
|
+
"info",
|
|
154
|
+
r"\b(THROW|RAISERROR)\b",
|
|
155
|
+
"Has error paths. Errors are behavior too: add cases that trigger them.",
|
|
156
|
+
),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
_OWN_HEADER = re.compile(r"^\s*(CREATE|ALTER)\s+(OR\s+ALTER\s+)?PROC(EDURE)?\b", re.IGNORECASE)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def analyze_risks(definition: str) -> list[Risk]:
|
|
164
|
+
# The procedure's own "CREATE PROCEDURE" header is not DDL executed by the procedure.
|
|
165
|
+
text = _OWN_HEADER.sub(" ", strip_comments_and_strings(definition), count=1)
|
|
166
|
+
risks: list[Risk] = []
|
|
167
|
+
for code, severity, pattern, message in _RISK_RULES:
|
|
168
|
+
matches = list(re.finditer(pattern, text, flags=re.IGNORECASE | re.DOTALL))
|
|
169
|
+
if matches:
|
|
170
|
+
evidence = sorted({" ".join(m.group(0).split())[:60] for m in matches})[:5]
|
|
171
|
+
risks.append(Risk(code=code, severity=severity, message=message, evidence=evidence))
|
|
172
|
+
return risks
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def count_branches(definition: str) -> int:
|
|
176
|
+
"""Rough number of decision points, used to suggest how many cases to capture."""
|
|
177
|
+
text = strip_comments_and_strings(definition)
|
|
178
|
+
return len(re.findall(r"\b(IF|CASE|WHILE|THROW|RAISERROR)\b", text, flags=re.IGNORECASE))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_WRITE_TARGET = re.compile(
|
|
182
|
+
r"\b(?:INSERT\s+(?:INTO\s+)?|UPDATE\s+|DELETE\s+(?:FROM\s+)?|MERGE\s+(?:INTO\s+)?|TRUNCATE\s+TABLE\s+)"
|
|
183
|
+
r"((?:\[[^\]]+\]|\w+)(?:\s*\.\s*(?:\[[^\]]+\]|\w+))?)",
|
|
184
|
+
flags=re.IGNORECASE,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def write_targets_from_text(definition: str) -> list[str]:
|
|
189
|
+
"""Candidate table names written by INSERT/UPDATE/DELETE/MERGE/TRUNCATE."""
|
|
190
|
+
text = strip_comments_and_strings(definition)
|
|
191
|
+
found = []
|
|
192
|
+
for match in _WRITE_TARGET.finditer(text):
|
|
193
|
+
name = re.sub(r"\s+", "", match.group(1))
|
|
194
|
+
if name.startswith(("#", "@")) or name.upper() in {"TOP", "STATISTICS"}:
|
|
195
|
+
continue
|
|
196
|
+
found.append(name)
|
|
197
|
+
return sorted(set(found))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------------------
|
|
201
|
+
# Catalog queries
|
|
202
|
+
# --------------------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class Parameter:
|
|
207
|
+
name: str
|
|
208
|
+
type: str
|
|
209
|
+
is_output: bool
|
|
210
|
+
is_table_type: bool
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class ResultColumn:
|
|
215
|
+
name: str
|
|
216
|
+
type: str
|
|
217
|
+
nullable: bool
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class ProcedureInfo:
|
|
222
|
+
schema: str
|
|
223
|
+
name: str
|
|
224
|
+
object_id: int
|
|
225
|
+
definition: str
|
|
226
|
+
definition_hash: str
|
|
227
|
+
parameters: list[Parameter]
|
|
228
|
+
reads: list[str]
|
|
229
|
+
writes: list[str]
|
|
230
|
+
calls: list[str]
|
|
231
|
+
triggers: list[str]
|
|
232
|
+
first_result_set: list[ResultColumn] | None
|
|
233
|
+
risks: list[Risk]
|
|
234
|
+
suggested_case_count: int
|
|
235
|
+
notes: list[str] = field(default_factory=list)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def full_name(self) -> str:
|
|
239
|
+
return f"{self.schema}.{self.name}"
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def quoted_name(self) -> str:
|
|
243
|
+
return quote_name(self.schema, self.name)
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def blocked(self) -> list[Risk]:
|
|
247
|
+
return [r for r in self.risks if r.severity == "block"]
|
|
248
|
+
|
|
249
|
+
def to_dict(self, include_definition: bool = False) -> dict[str, Any]:
|
|
250
|
+
data = asdict(self)
|
|
251
|
+
data["full_name"] = self.full_name
|
|
252
|
+
if not include_definition:
|
|
253
|
+
data.pop("definition")
|
|
254
|
+
return data
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class InspectionError(Exception):
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def resolve_procedure(db: Database, name: str) -> tuple[int, str, str]:
|
|
262
|
+
rows = db.query(
|
|
263
|
+
"SELECT o.object_id, s.name AS schema_name, o.name AS name, o.type AS type "
|
|
264
|
+
"FROM sys.objects o JOIN sys.schemas s ON s.schema_id = o.schema_id "
|
|
265
|
+
"WHERE o.object_id = OBJECT_ID(?)",
|
|
266
|
+
(name,),
|
|
267
|
+
)
|
|
268
|
+
if not rows:
|
|
269
|
+
raise InspectionError(f"Stored procedure '{name}' was not found in the current database.")
|
|
270
|
+
row = rows[0]
|
|
271
|
+
if row["type"].strip() != "P":
|
|
272
|
+
raise InspectionError(f"'{name}' is not a T-SQL stored procedure (type {row['type'].strip()}).")
|
|
273
|
+
return row["object_id"], row["schema_name"], row["name"]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _parameters(db: Database, object_id: int) -> list[Parameter]:
|
|
277
|
+
rows = db.query(
|
|
278
|
+
"SELECT p.name, TYPE_NAME(p.system_type_id) AS system_type, TYPE_NAME(p.user_type_id) AS user_type, "
|
|
279
|
+
"p.max_length, p.precision, p.scale, p.is_output, t.is_table_type "
|
|
280
|
+
"FROM sys.parameters p JOIN sys.types t ON t.user_type_id = p.user_type_id "
|
|
281
|
+
"WHERE p.object_id = ? AND p.parameter_id > 0 ORDER BY p.parameter_id",
|
|
282
|
+
(object_id,),
|
|
283
|
+
)
|
|
284
|
+
params = []
|
|
285
|
+
for r in rows:
|
|
286
|
+
is_table = bool(r["is_table_type"])
|
|
287
|
+
declared = r["user_type"] if is_table else type_decl(r["system_type"], r["max_length"], r["precision"], r["scale"])
|
|
288
|
+
params.append(Parameter(name=r["name"], type=declared, is_output=bool(r["is_output"]), is_table_type=is_table))
|
|
289
|
+
return params
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _references(db: Database, qualified: str) -> list[dict[str, Any]]:
|
|
293
|
+
return db.query(
|
|
294
|
+
"SELECT COALESCE(OBJECT_SCHEMA_NAME(r.referenced_id), r.referenced_schema_name, 'dbo') AS schema_name, "
|
|
295
|
+
"r.referenced_entity_name AS name, o.type AS type, "
|
|
296
|
+
"MAX(CAST(r.is_selected AS int)) AS is_selected, MAX(CAST(r.is_updated AS int)) AS is_updated "
|
|
297
|
+
"FROM sys.dm_sql_referenced_entities(?, 'OBJECT') r "
|
|
298
|
+
"LEFT JOIN sys.objects o ON o.object_id = r.referenced_id "
|
|
299
|
+
"WHERE r.referenced_class = 1 "
|
|
300
|
+
"GROUP BY COALESCE(OBJECT_SCHEMA_NAME(r.referenced_id), r.referenced_schema_name, 'dbo'), "
|
|
301
|
+
"r.referenced_entity_name, o.type",
|
|
302
|
+
(qualified,),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _resolve_table(db: Database, candidate: str) -> str | None:
|
|
307
|
+
rows = db.query(
|
|
308
|
+
"SELECT OBJECT_SCHEMA_NAME(OBJECT_ID(?, 'U')) AS schema_name, OBJECT_NAME(OBJECT_ID(?, 'U')) AS name",
|
|
309
|
+
(candidate, candidate),
|
|
310
|
+
)
|
|
311
|
+
if rows and rows[0]["name"]:
|
|
312
|
+
return f"{rows[0]['schema_name']}.{rows[0]['name']}"
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _triggers(db: Database, table: str) -> list[str]:
|
|
317
|
+
rows = db.query(
|
|
318
|
+
"SELECT OBJECT_SCHEMA_NAME(t.object_id) AS schema_name, t.name "
|
|
319
|
+
"FROM sys.triggers t WHERE t.parent_id = OBJECT_ID(?) AND t.is_disabled = 0",
|
|
320
|
+
(table,),
|
|
321
|
+
)
|
|
322
|
+
return [f"{r['schema_name']}.{r['name']}" for r in rows]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _first_result_set(db: Database, object_id: int) -> list[ResultColumn] | None:
|
|
326
|
+
rows = db.query(
|
|
327
|
+
"SELECT name, system_type_name, is_nullable, error_message "
|
|
328
|
+
"FROM sys.dm_exec_describe_first_result_set_for_object(?, 0) "
|
|
329
|
+
"WHERE is_hidden = 0 OR is_hidden IS NULL ORDER BY column_ordinal",
|
|
330
|
+
(object_id,),
|
|
331
|
+
)
|
|
332
|
+
if not rows or any(r.get("error_message") for r in rows):
|
|
333
|
+
return None
|
|
334
|
+
return [ResultColumn(name=r["name"], type=r["system_type_name"], nullable=bool(r["is_nullable"])) for r in rows]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _collect_effects(db: Database, qualified: str, definition: str, depth: int, seen: set[str],
|
|
338
|
+
reads: set[str], writes: set[str], calls: set[str], notes: list[str]) -> None:
|
|
339
|
+
"""Walk the procedure (and procedures/triggers it reaches) collecting table reads and writes."""
|
|
340
|
+
if qualified.lower() in seen or depth > 5:
|
|
341
|
+
return
|
|
342
|
+
seen.add(qualified.lower())
|
|
343
|
+
try:
|
|
344
|
+
refs = _references(db, qualified)
|
|
345
|
+
except Exception as exc: # metadata can fail when the code references missing objects
|
|
346
|
+
notes.append(f"Could not read dependency metadata for {qualified}: {exc}. Falling back to text analysis.")
|
|
347
|
+
refs = []
|
|
348
|
+
for ref in refs:
|
|
349
|
+
full = f"{ref['schema_name']}.{ref['name']}"
|
|
350
|
+
kind = (ref["type"] or "").strip()
|
|
351
|
+
if kind == "U":
|
|
352
|
+
if ref["is_updated"]:
|
|
353
|
+
writes.add(full)
|
|
354
|
+
else:
|
|
355
|
+
reads.add(full)
|
|
356
|
+
elif kind == "P":
|
|
357
|
+
calls.add(full)
|
|
358
|
+
inner = db.query("SELECT OBJECT_DEFINITION(OBJECT_ID(?)) AS definition", (full,))
|
|
359
|
+
inner_def = inner[0]["definition"] if inner else None
|
|
360
|
+
if inner_def:
|
|
361
|
+
_collect_effects(db, full, inner_def, depth + 1, seen, reads, writes, calls, notes)
|
|
362
|
+
elif kind in ("V", "FN", "IF", "TF"):
|
|
363
|
+
reads.add(full)
|
|
364
|
+
for candidate in write_targets_from_text(definition):
|
|
365
|
+
resolved = _resolve_table(db, candidate)
|
|
366
|
+
if resolved:
|
|
367
|
+
writes.add(resolved)
|
|
368
|
+
reads.difference_update(writes)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def inspect_procedure(db: Database, name: str) -> ProcedureInfo:
|
|
372
|
+
object_id, schema, proc = resolve_procedure(db, name)
|
|
373
|
+
qualified = f"{schema}.{proc}"
|
|
374
|
+
definition = db.query("SELECT OBJECT_DEFINITION(?) AS definition", (object_id,))[0]["definition"]
|
|
375
|
+
if not definition:
|
|
376
|
+
raise InspectionError(f"The definition of {qualified} is not readable (encrypted or no VIEW DEFINITION permission).")
|
|
377
|
+
|
|
378
|
+
reads: set[str] = set()
|
|
379
|
+
writes: set[str] = set()
|
|
380
|
+
calls: set[str] = set()
|
|
381
|
+
notes: list[str] = []
|
|
382
|
+
_collect_effects(db, qualified, definition, 0, set(), reads, writes, calls, notes)
|
|
383
|
+
|
|
384
|
+
# Triggers on written tables can write more tables.
|
|
385
|
+
triggers: list[str] = []
|
|
386
|
+
pending = sorted(writes)
|
|
387
|
+
seen_tables: set[str] = set()
|
|
388
|
+
while pending:
|
|
389
|
+
table = pending.pop()
|
|
390
|
+
if table in seen_tables:
|
|
391
|
+
continue
|
|
392
|
+
seen_tables.add(table)
|
|
393
|
+
for trigger in _triggers(db, table):
|
|
394
|
+
triggers.append(trigger)
|
|
395
|
+
trig_def = db.query("SELECT OBJECT_DEFINITION(OBJECT_ID(?)) AS definition", (trigger,))[0]["definition"] or ""
|
|
396
|
+
before = set(writes)
|
|
397
|
+
_collect_effects(db, trigger, trig_def, 1, set(), reads, writes, calls, notes)
|
|
398
|
+
pending.extend(sorted(writes - before))
|
|
399
|
+
|
|
400
|
+
risks = analyze_risks(definition)
|
|
401
|
+
if triggers:
|
|
402
|
+
risks.append(Risk("triggers", "info", "Triggers run when this procedure writes; their changes are included.", sorted(triggers)))
|
|
403
|
+
parameters = _parameters(db, object_id)
|
|
404
|
+
if any(p.is_table_type for p in parameters):
|
|
405
|
+
risks.append(Risk("table_valued_parameter", "block",
|
|
406
|
+
"Table-valued parameters are not supported yet in sprocguard 0.1.",
|
|
407
|
+
[p.name for p in parameters if p.is_table_type]))
|
|
408
|
+
try:
|
|
409
|
+
first = _first_result_set(db, object_id)
|
|
410
|
+
except Exception:
|
|
411
|
+
first = None
|
|
412
|
+
|
|
413
|
+
return ProcedureInfo(
|
|
414
|
+
schema=schema,
|
|
415
|
+
name=proc,
|
|
416
|
+
object_id=object_id,
|
|
417
|
+
definition=definition,
|
|
418
|
+
definition_hash=hashlib.sha256(definition.encode("utf-8")).hexdigest(),
|
|
419
|
+
parameters=parameters,
|
|
420
|
+
reads=sorted(reads),
|
|
421
|
+
writes=sorted(writes),
|
|
422
|
+
calls=sorted(calls),
|
|
423
|
+
triggers=sorted(set(triggers)),
|
|
424
|
+
first_result_set=first,
|
|
425
|
+
risks=risks,
|
|
426
|
+
suggested_case_count=min(12, 2 + count_branches(definition)),
|
|
427
|
+
notes=notes,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# --------------------------------------------------------------------------------------
|
|
432
|
+
# Case suggestions
|
|
433
|
+
# --------------------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
_EDGE_VALUES = {
|
|
437
|
+
"int": [0, -1], "bigint": [0, -1], "smallint": [0, -1], "tinyint": [0, 255],
|
|
438
|
+
"bit": [0, 1],
|
|
439
|
+
"decimal": ["0", "-1"], "numeric": ["0", "-1"], "money": ["0", "-1"], "float": [0.0, -1.0],
|
|
440
|
+
"varchar": ["", "x" * 50], "nvarchar": ["", "ñandú"], "char": [""], "nchar": [""],
|
|
441
|
+
"date": ["1900-01-01", "9999-12-31"], "datetime": ["1900-01-01T00:00:00"], "datetime2": ["0001-01-01T00:00:00"],
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def suggest_cases(db: Database, info: ProcedureInfo, samples_per_param: int = 3) -> dict[str, Any]:
|
|
446
|
+
"""Suggest parameter values: real values from matching columns plus edge values."""
|
|
447
|
+
tables = info.reads + info.writes
|
|
448
|
+
suggestions: dict[str, Any] = {}
|
|
449
|
+
for param in info.parameters:
|
|
450
|
+
if param.is_table_type:
|
|
451
|
+
continue
|
|
452
|
+
column = param.name.lstrip("@")
|
|
453
|
+
real: list[Any] = []
|
|
454
|
+
source = None
|
|
455
|
+
for table in tables:
|
|
456
|
+
schema, tname = table.split(".", 1)
|
|
457
|
+
exists = db.query(
|
|
458
|
+
"SELECT 1 AS ok FROM sys.columns WHERE object_id = OBJECT_ID(?) AND name = ?",
|
|
459
|
+
(table, column),
|
|
460
|
+
)
|
|
461
|
+
if exists:
|
|
462
|
+
rows = db.query(
|
|
463
|
+
f"SELECT DISTINCT TOP ({int(samples_per_param)}) {quote_name(column)} AS v "
|
|
464
|
+
f"FROM {quote_name(schema, tname)} WHERE {quote_name(column)} IS NOT NULL",
|
|
465
|
+
)
|
|
466
|
+
real = [r["v"] for r in rows]
|
|
467
|
+
source = f"{table}.{column}"
|
|
468
|
+
break
|
|
469
|
+
base_type = param.type.split("(")[0]
|
|
470
|
+
suggestions[param.name] = {
|
|
471
|
+
"type": param.type,
|
|
472
|
+
"is_output": param.is_output,
|
|
473
|
+
"real_values": real,
|
|
474
|
+
"real_values_from": source,
|
|
475
|
+
"edge_values": _EDGE_VALUES.get(base_type, []) + [None],
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
"procedure": info.full_name,
|
|
479
|
+
"suggested_case_count": info.suggested_case_count,
|
|
480
|
+
"parameters": suggestions,
|
|
481
|
+
"advice": (
|
|
482
|
+
"Pick cases that cover each branch (IF/CASE), each error path (THROW/RAISERROR), "
|
|
483
|
+
"an empty result, and boundary values. Use real values so the procedure finds data."
|
|
484
|
+
),
|
|
485
|
+
}
|