codegraph-engine 3.0.0__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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
"""Pydantic, Django Form, and DB Schema Drift Detection Engine (`src/codegraph/database/schema_drift.py`).
|
|
2
|
+
|
|
3
|
+
Provides deterministic, AST-verified cross-layer drift detection:
|
|
4
|
+
- Route input schemas (Pydantic BaseModel, Django Form/ModelForm, DRF Serializer, dataclasses)
|
|
5
|
+
versus destination database table columns.
|
|
6
|
+
- Drift categories:
|
|
7
|
+
1. MISSING_REQUIRED_COLUMN: DB column is NOT NULL with no default, but route schema lacks the field.
|
|
8
|
+
2. NULLABILITY_MISMATCH: Route schema permits None/null, but DB column rejects NULLs.
|
|
9
|
+
3. TYPE_INCOMPATIBILITY: Route schema type conflicts with DB column type (e.g., int vs DATE/TIMESTAMP).
|
|
10
|
+
4. LENGTH_CONSTRAINT_DRIFT: Route schema allows max_length > DB column length limit.
|
|
11
|
+
5. UNUSED_SCHEMA_FIELD: Route schema accepts a field that is never persisted to the table.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sqlite3
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from codegraph.database.interrogation import get_route_db_lineage
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DriftSeverity(StrEnum):
|
|
28
|
+
CRITICAL = "CRITICAL"
|
|
29
|
+
WARNING = "WARNING"
|
|
30
|
+
INFO = "INFO"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DriftType(StrEnum):
|
|
34
|
+
MISSING_REQUIRED_COLUMN = "MISSING_REQUIRED_COLUMN"
|
|
35
|
+
NULLABILITY_MISMATCH = "NULLABILITY_MISMATCH"
|
|
36
|
+
TYPE_INCOMPATIBILITY = "TYPE_INCOMPATIBILITY"
|
|
37
|
+
LENGTH_CONSTRAINT_DRIFT = "LENGTH_CONSTRAINT_DRIFT"
|
|
38
|
+
UNUSED_SCHEMA_FIELD = "UNUSED_SCHEMA_FIELD"
|
|
39
|
+
RUNTIME_MISSING_REQUIRED_COLUMN = "RUNTIME_MISSING_REQUIRED_COLUMN"
|
|
40
|
+
RUNTIME_UNKNOWN_COLUMN_WRITTEN = "RUNTIME_UNKNOWN_COLUMN_WRITTEN"
|
|
41
|
+
STATIC_RUNTIME_DIVERGENCE = "STATIC_RUNTIME_DIVERGENCE"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SchemaFieldSpec:
|
|
46
|
+
name: str
|
|
47
|
+
type_name: str
|
|
48
|
+
nullable: bool
|
|
49
|
+
required: bool
|
|
50
|
+
max_length: int | None = None
|
|
51
|
+
default_repr: str | None = None
|
|
52
|
+
file_path: str = ""
|
|
53
|
+
line: int = 0
|
|
54
|
+
|
|
55
|
+
def as_dict(self) -> dict[str, Any]:
|
|
56
|
+
return asdict(self)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class SchemaDriftIssue:
|
|
61
|
+
drift_type: str
|
|
62
|
+
field_name: str
|
|
63
|
+
column_name: str | None
|
|
64
|
+
severity: str
|
|
65
|
+
message: str
|
|
66
|
+
schema_field: dict[str, Any] | None
|
|
67
|
+
db_column: dict[str, Any] | None
|
|
68
|
+
evidence: str
|
|
69
|
+
|
|
70
|
+
def as_dict(self) -> dict[str, Any]:
|
|
71
|
+
return asdict(self)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# AST Extraction for Pydantic / dataclass / Form Schemas
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
_IGNORED_AUTO_COLUMNS = frozenset({"id", "created_at", "updated_at", "created_on", "updated_on", "deleted_at"})
|
|
79
|
+
_IGNORED_SCHEMA_FIELDS = frozenset({"csrf_token", "csrfmiddlewaretoken", "captcha"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _unparse_ast(node: ast.AST | None) -> str:
|
|
83
|
+
if node is None:
|
|
84
|
+
return ""
|
|
85
|
+
try:
|
|
86
|
+
return ast.unparse(node).strip()
|
|
87
|
+
except Exception:
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _extract_type_info(annotation_node: ast.AST | None) -> tuple[str, bool]:
|
|
92
|
+
"""Return (base_type_name, is_nullable)."""
|
|
93
|
+
if annotation_node is None:
|
|
94
|
+
return "Any", True
|
|
95
|
+
|
|
96
|
+
raw_str = _unparse_ast(annotation_node)
|
|
97
|
+
|
|
98
|
+
# Check for Optional[...] or None in Union
|
|
99
|
+
is_nullable = False
|
|
100
|
+
if "None" in raw_str or "Optional" in raw_str:
|
|
101
|
+
is_nullable = True
|
|
102
|
+
|
|
103
|
+
# Normalize base type
|
|
104
|
+
base = raw_str.replace("Optional[", "").replace("]", "")
|
|
105
|
+
parts = [p.strip() for p in base.split("|") if p.strip() and p.strip() != "None"]
|
|
106
|
+
base_clean = parts[0] if parts else "Any"
|
|
107
|
+
|
|
108
|
+
# Normalize common wrappers like Annotated[str, Field(...)]
|
|
109
|
+
if base_clean.startswith("Annotated["):
|
|
110
|
+
inner = base_clean[len("Annotated[") : -1]
|
|
111
|
+
base_clean = inner.split(",")[0].strip()
|
|
112
|
+
|
|
113
|
+
return base_clean, is_nullable
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_field_call(call_node: ast.Call) -> tuple[bool, bool, int | None, str | None]:
|
|
117
|
+
"""Parse a Field(...) or Form field call for (required, nullable, max_length, default_repr)."""
|
|
118
|
+
required = True
|
|
119
|
+
nullable = False
|
|
120
|
+
max_length: int | None = None
|
|
121
|
+
default_repr: str | None = None
|
|
122
|
+
|
|
123
|
+
# Positional args (e.g. Field(..., max_length=50) or Field(None))
|
|
124
|
+
if call_node.args:
|
|
125
|
+
first_arg = call_node.args[0]
|
|
126
|
+
arg_str = _unparse_ast(first_arg)
|
|
127
|
+
if arg_str == "...":
|
|
128
|
+
required = True
|
|
129
|
+
elif arg_str == "None":
|
|
130
|
+
required = False
|
|
131
|
+
nullable = True
|
|
132
|
+
default_repr = "None"
|
|
133
|
+
else:
|
|
134
|
+
required = False
|
|
135
|
+
default_repr = arg_str
|
|
136
|
+
|
|
137
|
+
for kw in call_node.keywords:
|
|
138
|
+
kw_name = kw.arg
|
|
139
|
+
kw_val = _unparse_ast(kw.value)
|
|
140
|
+
if kw_name == "default":
|
|
141
|
+
if kw_val == "...":
|
|
142
|
+
required = True
|
|
143
|
+
elif kw_val == "None":
|
|
144
|
+
required = False
|
|
145
|
+
nullable = True
|
|
146
|
+
default_repr = "None"
|
|
147
|
+
else:
|
|
148
|
+
required = False
|
|
149
|
+
default_repr = kw_val
|
|
150
|
+
elif kw_name == "default_factory":
|
|
151
|
+
required = False
|
|
152
|
+
default_repr = f"{kw_val}()"
|
|
153
|
+
elif kw_name == "max_length":
|
|
154
|
+
try:
|
|
155
|
+
max_length = int(kw_val)
|
|
156
|
+
except ValueError:
|
|
157
|
+
pass
|
|
158
|
+
elif kw_name == "required":
|
|
159
|
+
if kw_val in ("False", "0"):
|
|
160
|
+
required = False
|
|
161
|
+
nullable = True
|
|
162
|
+
elif kw_val in ("True", "1"):
|
|
163
|
+
required = True
|
|
164
|
+
|
|
165
|
+
return required, nullable, max_length, default_repr
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def extract_schema_fields_from_class_node(
|
|
169
|
+
class_node: ast.ClassDef,
|
|
170
|
+
file_path: str,
|
|
171
|
+
) -> list[SchemaFieldSpec]:
|
|
172
|
+
"""Extract fields from a Pydantic BaseModel, dataclass, or Django Form AST node."""
|
|
173
|
+
fields: list[SchemaFieldSpec] = []
|
|
174
|
+
|
|
175
|
+
for stmt in class_node.body:
|
|
176
|
+
# 1. Annotated assignments: field: type [= default]
|
|
177
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
178
|
+
f_name = stmt.target.id
|
|
179
|
+
if f_name.startswith("_"):
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
base_type, is_nullable = _extract_type_info(stmt.annotation)
|
|
183
|
+
required = True
|
|
184
|
+
max_length: int | None = None
|
|
185
|
+
default_repr: str | None = None
|
|
186
|
+
|
|
187
|
+
if stmt.value is not None:
|
|
188
|
+
val_str = _unparse_ast(stmt.value)
|
|
189
|
+
if isinstance(stmt.value, ast.Call):
|
|
190
|
+
# Check for Field(...) or forms.CharField(...)
|
|
191
|
+
call_func_name = _unparse_ast(stmt.value.func)
|
|
192
|
+
if "Field" in call_func_name or "forms." in call_func_name or "serializers." in call_func_name:
|
|
193
|
+
req, null, m_len, d_rep = _parse_field_call(stmt.value)
|
|
194
|
+
required = req
|
|
195
|
+
if null:
|
|
196
|
+
is_nullable = True
|
|
197
|
+
if m_len is not None:
|
|
198
|
+
max_length = m_len
|
|
199
|
+
default_repr = d_rep
|
|
200
|
+
else:
|
|
201
|
+
required = False
|
|
202
|
+
default_repr = val_str
|
|
203
|
+
elif val_str == "...":
|
|
204
|
+
required = True
|
|
205
|
+
elif val_str == "None":
|
|
206
|
+
required = False
|
|
207
|
+
is_nullable = True
|
|
208
|
+
default_repr = "None"
|
|
209
|
+
else:
|
|
210
|
+
required = False
|
|
211
|
+
default_repr = val_str
|
|
212
|
+
|
|
213
|
+
fields.append(
|
|
214
|
+
SchemaFieldSpec(
|
|
215
|
+
name=f_name,
|
|
216
|
+
type_name=base_type,
|
|
217
|
+
nullable=is_nullable,
|
|
218
|
+
required=required,
|
|
219
|
+
max_length=max_length,
|
|
220
|
+
default_repr=default_repr,
|
|
221
|
+
file_path=file_path,
|
|
222
|
+
line=stmt.lineno,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# 2. Assign statements (e.g. Django forms / DRF serializers: name = forms.CharField(...))
|
|
227
|
+
elif isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
|
|
228
|
+
f_name = stmt.targets[0].id
|
|
229
|
+
if f_name.startswith("_"):
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if isinstance(stmt.value, ast.Call):
|
|
233
|
+
func_name = _unparse_ast(stmt.value.func)
|
|
234
|
+
# Map Django / DRF fields to types
|
|
235
|
+
inferred_type = "str"
|
|
236
|
+
if "Integer" in func_name or "BigInteger" in func_name:
|
|
237
|
+
inferred_type = "int"
|
|
238
|
+
elif "Float" in func_name or "Decimal" in func_name:
|
|
239
|
+
inferred_type = "float"
|
|
240
|
+
elif "Boolean" in func_name:
|
|
241
|
+
inferred_type = "bool"
|
|
242
|
+
elif "Date" in func_name:
|
|
243
|
+
inferred_type = "date"
|
|
244
|
+
elif "Time" in func_name:
|
|
245
|
+
inferred_type = "datetime"
|
|
246
|
+
elif "JSON" in func_name:
|
|
247
|
+
inferred_type = "dict"
|
|
248
|
+
elif "UUID" in func_name:
|
|
249
|
+
inferred_type = "UUID"
|
|
250
|
+
|
|
251
|
+
req, null, m_len, d_rep = _parse_field_call(stmt.value)
|
|
252
|
+
fields.append(
|
|
253
|
+
SchemaFieldSpec(
|
|
254
|
+
name=f_name,
|
|
255
|
+
type_name=inferred_type,
|
|
256
|
+
nullable=null,
|
|
257
|
+
required=req,
|
|
258
|
+
max_length=m_len,
|
|
259
|
+
default_repr=d_rep,
|
|
260
|
+
file_path=file_path,
|
|
261
|
+
line=stmt.lineno,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return fields
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def extract_schema_fields_from_file(
|
|
269
|
+
file_path: Path,
|
|
270
|
+
class_name: str,
|
|
271
|
+
) -> list[SchemaFieldSpec]:
|
|
272
|
+
"""Parse file_path and extract SchemaFieldSpecs for class_name."""
|
|
273
|
+
if not file_path.exists():
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
278
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
279
|
+
except Exception:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
for node in ast.walk(tree):
|
|
283
|
+
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
|
284
|
+
return extract_schema_fields_from_class_node(node, str(file_path))
|
|
285
|
+
|
|
286
|
+
return []
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def extract_dynamic_request_fields(
|
|
290
|
+
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
291
|
+
file_path: str,
|
|
292
|
+
) -> list[SchemaFieldSpec]:
|
|
293
|
+
"""Extract dynamic form/json/query parameters accessed on Flask/FastAPI/Django request objects."""
|
|
294
|
+
fields: dict[str, SchemaFieldSpec] = {}
|
|
295
|
+
|
|
296
|
+
for node in ast.walk(func_node):
|
|
297
|
+
# 1. request.form.get("key") or request.json.get("key") or request.args.get("key")
|
|
298
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "get":
|
|
299
|
+
base = node.func.value
|
|
300
|
+
if isinstance(base, ast.Attribute) and base.attr in ("form", "args", "json", "values", "GET", "POST"):
|
|
301
|
+
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
|
302
|
+
f_name = node.args[0].value
|
|
303
|
+
has_default = len(node.args) > 1 or any(kw.arg == "default" for kw in node.keywords)
|
|
304
|
+
if f_name not in fields:
|
|
305
|
+
fields[f_name] = SchemaFieldSpec(
|
|
306
|
+
name=f_name,
|
|
307
|
+
type_name="Any",
|
|
308
|
+
nullable=True,
|
|
309
|
+
required=not has_default,
|
|
310
|
+
default_repr="None" if not has_default else "...",
|
|
311
|
+
file_path=file_path,
|
|
312
|
+
line=node.lineno,
|
|
313
|
+
)
|
|
314
|
+
# Also handle request.get_json().get("name")
|
|
315
|
+
elif isinstance(base, ast.Call) and isinstance(base.func, ast.Attribute) and base.func.attr == "get_json":
|
|
316
|
+
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
|
317
|
+
f_name = node.args[0].value
|
|
318
|
+
if f_name not in fields:
|
|
319
|
+
fields[f_name] = SchemaFieldSpec(
|
|
320
|
+
name=f_name,
|
|
321
|
+
type_name="Any",
|
|
322
|
+
nullable=True,
|
|
323
|
+
required=False,
|
|
324
|
+
file_path=file_path,
|
|
325
|
+
line=node.lineno,
|
|
326
|
+
)
|
|
327
|
+
# 2. request.form["key"] or request.json["key"]
|
|
328
|
+
elif isinstance(node, ast.Subscript) and isinstance(node.value, ast.Attribute) and node.value.attr in ("form", "args", "json", "values", "GET", "POST"):
|
|
329
|
+
slice_node = node.slice
|
|
330
|
+
if isinstance(slice_node, ast.Constant) and isinstance(slice_node.value, str):
|
|
331
|
+
f_name = slice_node.value
|
|
332
|
+
fields[f_name] = SchemaFieldSpec(
|
|
333
|
+
name=f_name,
|
|
334
|
+
type_name="Any",
|
|
335
|
+
nullable=False,
|
|
336
|
+
required=True,
|
|
337
|
+
file_path=file_path,
|
|
338
|
+
line=node.lineno,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return list(fields.values())
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def resolve_handler_input_schema(
|
|
345
|
+
con: sqlite3.Connection,
|
|
346
|
+
repo_path: Path,
|
|
347
|
+
handler_symbol_or_path: str,
|
|
348
|
+
) -> tuple[str, list[SchemaFieldSpec]] | None:
|
|
349
|
+
"""Find the route handler AST, discover parameter type annotations, and extract the input schema."""
|
|
350
|
+
# Look up handler in symbols table
|
|
351
|
+
sym_row = con.execute(
|
|
352
|
+
"SELECT name, path, start_line, end_line, signature FROM symbols "
|
|
353
|
+
"WHERE (canonical_id = ? OR qualified_name = ? OR name = ?) AND kind in ('function', 'method') LIMIT 1",
|
|
354
|
+
(handler_symbol_or_path, handler_symbol_or_path, handler_symbol_or_path),
|
|
355
|
+
).fetchone()
|
|
356
|
+
|
|
357
|
+
if not sym_row:
|
|
358
|
+
# Try finding handler via framework_routes
|
|
359
|
+
rt_row = con.execute(
|
|
360
|
+
"SELECT handler_name, file_path, line FROM framework_routes "
|
|
361
|
+
"WHERE (handler_name = ? OR handler_canonical_id = ? OR route_path = ?) LIMIT 1",
|
|
362
|
+
(handler_symbol_or_path, handler_symbol_or_path, handler_symbol_or_path),
|
|
363
|
+
).fetchone()
|
|
364
|
+
if rt_row:
|
|
365
|
+
h_path = repo_path / str(rt_row[1])
|
|
366
|
+
h_name = str(rt_row[0])
|
|
367
|
+
else:
|
|
368
|
+
return None
|
|
369
|
+
else:
|
|
370
|
+
h_path = repo_path / str(sym_row["path"])
|
|
371
|
+
h_name = str(sym_row["name"])
|
|
372
|
+
|
|
373
|
+
if not h_path.exists():
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
source = h_path.read_text(encoding="utf-8", errors="replace")
|
|
378
|
+
tree = ast.parse(source, filename=str(h_path))
|
|
379
|
+
except Exception:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
handler_fn: ast.FunctionDef | ast.AsyncFunctionDef | None = None
|
|
383
|
+
for node in ast.walk(tree):
|
|
384
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == h_name:
|
|
385
|
+
handler_fn = node
|
|
386
|
+
break
|
|
387
|
+
|
|
388
|
+
if not handler_fn:
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
# Inspect function parameters for schema types
|
|
392
|
+
# Exclude request, db, session, self, cls
|
|
393
|
+
excluded_params = {"request", "db", "session", "self", "cls", "background_tasks", "response"}
|
|
394
|
+
for arg in handler_fn.args.args:
|
|
395
|
+
if arg.arg in excluded_params:
|
|
396
|
+
continue
|
|
397
|
+
if arg.annotation:
|
|
398
|
+
ann_str = _unparse_ast(arg.annotation)
|
|
399
|
+
# Remove Optional / Union wrappers
|
|
400
|
+
schema_type = ann_str.replace("Optional[", "").replace("]", "").split("|")[0].strip()
|
|
401
|
+
# If schema_type is a defined class in this file or imported
|
|
402
|
+
# 1. Check current file for class definition
|
|
403
|
+
for c_node in ast.walk(tree):
|
|
404
|
+
if isinstance(c_node, ast.ClassDef) and c_node.name == schema_type:
|
|
405
|
+
specs = extract_schema_fields_from_class_node(c_node, str(h_path))
|
|
406
|
+
if specs:
|
|
407
|
+
return schema_type, specs
|
|
408
|
+
|
|
409
|
+
# 2. Direct lookup in symbols table for ClassDef
|
|
410
|
+
cls_row = con.execute(
|
|
411
|
+
"SELECT path FROM symbols WHERE name = ? AND kind = 'class' LIMIT 1",
|
|
412
|
+
(schema_type,),
|
|
413
|
+
).fetchone()
|
|
414
|
+
if cls_row:
|
|
415
|
+
c_path = repo_path / str(cls_row[0])
|
|
416
|
+
specs = extract_schema_fields_from_file(c_path, schema_type)
|
|
417
|
+
if specs:
|
|
418
|
+
return schema_type, specs
|
|
419
|
+
|
|
420
|
+
# 3. Check imports table in SQLite
|
|
421
|
+
try:
|
|
422
|
+
imp_rows = con.execute(
|
|
423
|
+
"SELECT module FROM imports WHERE source_path = ? AND (module LIKE ? OR module = ?)",
|
|
424
|
+
(str(h_path.relative_to(repo_path)), f"%{schema_type}%", schema_type),
|
|
425
|
+
).fetchall()
|
|
426
|
+
for imp in imp_rows:
|
|
427
|
+
mod_str = str(imp[0])
|
|
428
|
+
pot_rel = mod_str.replace(".", "/") + ".py"
|
|
429
|
+
pot_path = repo_path / pot_rel
|
|
430
|
+
if pot_path.exists():
|
|
431
|
+
specs = extract_schema_fields_from_file(pot_path, schema_type)
|
|
432
|
+
if specs:
|
|
433
|
+
return schema_type, specs
|
|
434
|
+
except sqlite3.OperationalError:
|
|
435
|
+
pass
|
|
436
|
+
|
|
437
|
+
# 4. Fallback: Dynamic payload extraction in handler body (Flask request.form / request.json / request.args)
|
|
438
|
+
dynamic_specs = extract_dynamic_request_fields(handler_fn, str(h_path))
|
|
439
|
+
if dynamic_specs:
|
|
440
|
+
return f"{h_name}:DynamicPayload", dynamic_specs
|
|
441
|
+
|
|
442
|
+
return None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
# ---------------------------------------------------------------------------
|
|
446
|
+
# Type Compatibility Matrix
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
_COMPATIBLE_TYPES: dict[str, set[str]] = {
|
|
450
|
+
"int": {"integer", "int", "bigint", "smallint", "serial", "bigserial", "tinyint", "integerfield"},
|
|
451
|
+
"str": {"varchar", "text", "char", "character varying", "string", "citext", "charfield", "emailfield"},
|
|
452
|
+
"float": {"float", "double", "real", "numeric", "decimal", "double precision", "floatfield", "decimalfield"},
|
|
453
|
+
"bool": {"boolean", "bool", "tinyint", "booleanfield"},
|
|
454
|
+
"date": {"date", "datefield"},
|
|
455
|
+
"datetime": {"timestamp", "timestamptz", "datetime", "timestamp with time zone", "timestamp without time zone", "datetimefield"},
|
|
456
|
+
"dict": {"json", "jsonb", "jsonfield"},
|
|
457
|
+
"list": {"json", "jsonb", "array"},
|
|
458
|
+
"UUID": {"uuid", "varchar", "char", "text", "uuidfield"},
|
|
459
|
+
"Any": set(), # Compatible with all
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _are_types_compatible(schema_type: str, db_col_type: str) -> bool:
|
|
464
|
+
s_norm = schema_type.lower()
|
|
465
|
+
d_norm = db_col_type.lower().split("(")[0].strip()
|
|
466
|
+
|
|
467
|
+
if s_norm in ("any", "object") or d_norm in ("any", "unknown", ""):
|
|
468
|
+
return True
|
|
469
|
+
|
|
470
|
+
# Normalize schema type name
|
|
471
|
+
target_key = "str"
|
|
472
|
+
if s_norm in ("int", "integer"):
|
|
473
|
+
target_key = "int"
|
|
474
|
+
elif s_norm in ("float", "decimal"):
|
|
475
|
+
target_key = "float"
|
|
476
|
+
elif s_norm in ("bool", "boolean"):
|
|
477
|
+
target_key = "bool"
|
|
478
|
+
elif s_norm in ("datetime",):
|
|
479
|
+
target_key = "datetime"
|
|
480
|
+
elif s_norm in ("date",):
|
|
481
|
+
target_key = "date"
|
|
482
|
+
elif s_norm in ("dict", "mapping"):
|
|
483
|
+
target_key = "dict"
|
|
484
|
+
elif s_norm in ("list", "sequence"):
|
|
485
|
+
target_key = "list"
|
|
486
|
+
elif s_norm in ("uuid",):
|
|
487
|
+
target_key = "UUID"
|
|
488
|
+
|
|
489
|
+
allowed = _COMPATIBLE_TYPES.get(target_key, set())
|
|
490
|
+
if not allowed:
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
return any(a in d_norm for a in allowed)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _extract_db_varchar_length(data_type: str) -> int | None:
|
|
497
|
+
m = re.search(r"(?:varchar|char|character varying|string|charfield)\s*\(\s*(\d+)\s*\)", data_type, re.IGNORECASE)
|
|
498
|
+
if m:
|
|
499
|
+
try:
|
|
500
|
+
return int(m.group(1))
|
|
501
|
+
except ValueError:
|
|
502
|
+
pass
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
# Schema Drift Interrogation Core
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def detect_schema_drift(
|
|
512
|
+
con: sqlite3.Connection,
|
|
513
|
+
repo_path: Path,
|
|
514
|
+
*,
|
|
515
|
+
route_or_handler: str,
|
|
516
|
+
target_table: str | None = None,
|
|
517
|
+
schema_name: str | None = None,
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
"""Detect schema validation drift between a route's input schema and the destination database table.
|
|
520
|
+
|
|
521
|
+
Deterministic verification of:
|
|
522
|
+
- MISSING_REQUIRED_COLUMN
|
|
523
|
+
- NULLABILITY_MISMATCH
|
|
524
|
+
- TYPE_INCOMPATIBILITY
|
|
525
|
+
- LENGTH_CONSTRAINT_DRIFT
|
|
526
|
+
- UNUSED_SCHEMA_FIELD
|
|
527
|
+
"""
|
|
528
|
+
# 1. Determine destination table via get_route_db_lineage if not explicitly provided
|
|
529
|
+
resolved_table = target_table
|
|
530
|
+
lineage = None
|
|
531
|
+
if not resolved_table:
|
|
532
|
+
lineage = get_route_db_lineage(con, route_or_handler)
|
|
533
|
+
writes = lineage.get("db_writes", [])
|
|
534
|
+
if writes:
|
|
535
|
+
resolved_table = writes[0].get("table")
|
|
536
|
+
elif lineage.get("tables"):
|
|
537
|
+
resolved_table = lineage["tables"][0]
|
|
538
|
+
|
|
539
|
+
# 1b. Fallback: check runtime observations & edges if static lineage did not resolve table
|
|
540
|
+
if not resolved_table:
|
|
541
|
+
rt_row = con.execute(
|
|
542
|
+
"SELECT db_table FROM runtime_observations "
|
|
543
|
+
"WHERE (route_path = ? OR handler_symbol = ? OR caller_symbol = ? OR route_path LIKE ? OR handler_symbol LIKE ?) "
|
|
544
|
+
"AND db_table != '' "
|
|
545
|
+
"ORDER BY runtime_generation DESC, rowid DESC LIMIT 1",
|
|
546
|
+
(route_or_handler, route_or_handler, route_or_handler, f"%{route_or_handler}%", f"%{route_or_handler}%"),
|
|
547
|
+
).fetchone()
|
|
548
|
+
if rt_row and rt_row[0]:
|
|
549
|
+
resolved_table = str(rt_row[0])
|
|
550
|
+
else:
|
|
551
|
+
edge_row = con.execute(
|
|
552
|
+
"SELECT target FROM runtime_edges "
|
|
553
|
+
"WHERE (source = ? OR source LIKE ?) AND relationship IN ('WRITES_TABLE', 'READS_TABLE') "
|
|
554
|
+
"ORDER BY observation_count DESC LIMIT 1",
|
|
555
|
+
(route_or_handler, f"%{route_or_handler}%"),
|
|
556
|
+
).fetchone()
|
|
557
|
+
if edge_row and edge_row[0]:
|
|
558
|
+
resolved_table = str(edge_row[0]).split(".")[-1]
|
|
559
|
+
|
|
560
|
+
if not resolved_table:
|
|
561
|
+
return {
|
|
562
|
+
"status": "error",
|
|
563
|
+
"route_or_handler": route_or_handler,
|
|
564
|
+
"drift_count": 0,
|
|
565
|
+
"issues": [],
|
|
566
|
+
"error": "No destination database table found for route or handler.",
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# 2. Extract input schema fields
|
|
570
|
+
fields: list[SchemaFieldSpec] = []
|
|
571
|
+
resolved_schema_name = schema_name
|
|
572
|
+
schema_mode = "STATIC_TYPED"
|
|
573
|
+
|
|
574
|
+
if resolved_schema_name:
|
|
575
|
+
cls_row = con.execute(
|
|
576
|
+
"SELECT path FROM symbols WHERE name = ? AND kind = 'class' LIMIT 1",
|
|
577
|
+
(resolved_schema_name,),
|
|
578
|
+
).fetchone()
|
|
579
|
+
if cls_row:
|
|
580
|
+
fields = extract_schema_fields_from_file(repo_path / str(cls_row[0]), resolved_schema_name)
|
|
581
|
+
else:
|
|
582
|
+
resolved = resolve_handler_input_schema(con, repo_path, route_or_handler)
|
|
583
|
+
if resolved:
|
|
584
|
+
resolved_schema_name, fields = resolved
|
|
585
|
+
if ":DynamicPayload" in resolved_schema_name:
|
|
586
|
+
schema_mode = "DYNAMIC_AST"
|
|
587
|
+
|
|
588
|
+
# Query runtime SQL observations for this route/handler and target table
|
|
589
|
+
rt_sql_rows = con.execute(
|
|
590
|
+
"SELECT db_operation, db_table, db_columns_json, normalized_sql, duration_ms, event_id "
|
|
591
|
+
"FROM runtime_observations "
|
|
592
|
+
"WHERE (route_path = ? OR handler_symbol = ? OR caller_symbol = ? OR route_path LIKE ? OR handler_symbol LIKE ?) "
|
|
593
|
+
"AND (db_table = ? OR db_table LIKE ?) AND normalized_sql != '' "
|
|
594
|
+
"ORDER BY runtime_generation DESC, rowid DESC",
|
|
595
|
+
(route_or_handler, route_or_handler, route_or_handler, f"%{route_or_handler}%", f"%{route_or_handler}%", resolved_table, f"%{resolved_table}%"),
|
|
596
|
+
).fetchall()
|
|
597
|
+
|
|
598
|
+
# If static/dynamic fields could not be found, attempt to build runtime schema fields from observed SQL
|
|
599
|
+
if not fields or not resolved_schema_name:
|
|
600
|
+
if rt_sql_rows:
|
|
601
|
+
seen_rt_cols: set[str] = set()
|
|
602
|
+
for r in rt_sql_rows:
|
|
603
|
+
try:
|
|
604
|
+
cols_list = json.loads(r[2] or "[]")
|
|
605
|
+
except Exception:
|
|
606
|
+
cols_list = []
|
|
607
|
+
for c in cols_list:
|
|
608
|
+
if c and c.lower() not in seen_rt_cols:
|
|
609
|
+
seen_rt_cols.add(c.lower())
|
|
610
|
+
fields.append(
|
|
611
|
+
SchemaFieldSpec(
|
|
612
|
+
name=c,
|
|
613
|
+
type_name="Any",
|
|
614
|
+
nullable=False,
|
|
615
|
+
required=True,
|
|
616
|
+
default_repr="<runtime_sql>",
|
|
617
|
+
file_path="[RUNTIME_OBSERVED]",
|
|
618
|
+
line=0,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
if fields:
|
|
622
|
+
resolved_schema_name = f"RuntimeSQL:{resolved_table}"
|
|
623
|
+
schema_mode = "RUNTIME_OBSERVED"
|
|
624
|
+
|
|
625
|
+
if not fields or not resolved_schema_name:
|
|
626
|
+
return {
|
|
627
|
+
"status": "error",
|
|
628
|
+
"route_or_handler": route_or_handler,
|
|
629
|
+
"target_table": resolved_table,
|
|
630
|
+
"drift_count": 0,
|
|
631
|
+
"issues": [],
|
|
632
|
+
"error": "Could not locate static schema class, dynamic request fields, or runtime SQL traces for handler.",
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
# 3. Retrieve database table columns from db_entities table
|
|
636
|
+
col_rows = con.execute(
|
|
637
|
+
"SELECT column_name, data_type, nullable, default_value, is_primary_key, is_foreign_key, file_path, start_line, evidence "
|
|
638
|
+
"FROM db_entities WHERE table_name = ? AND (LOWER(kind) = 'column' OR kind LIKE '%COLUMN%') ORDER BY column_name ASC",
|
|
639
|
+
(resolved_table,),
|
|
640
|
+
).fetchall()
|
|
641
|
+
|
|
642
|
+
if not col_rows:
|
|
643
|
+
return {
|
|
644
|
+
"status": "error",
|
|
645
|
+
"route_or_handler": route_or_handler,
|
|
646
|
+
"target_table": resolved_table,
|
|
647
|
+
"schema_name": resolved_schema_name,
|
|
648
|
+
"drift_count": 0,
|
|
649
|
+
"issues": [],
|
|
650
|
+
"error": f"No indexed columns found for destination table '{resolved_table}'.",
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
cols_by_name: dict[str, dict[str, Any]] = {}
|
|
654
|
+
for r in col_rows:
|
|
655
|
+
c_name = str(r[0]).lower()
|
|
656
|
+
cols_by_name[c_name] = {
|
|
657
|
+
"column_name": str(r[0]),
|
|
658
|
+
"data_type": str(r[1] or ""),
|
|
659
|
+
"nullable": bool(r[2]),
|
|
660
|
+
"default_value": r[3],
|
|
661
|
+
"is_primary_key": bool(r[4]),
|
|
662
|
+
"is_foreign_key": bool(r[5]),
|
|
663
|
+
"file_path": str(r[6]),
|
|
664
|
+
"line": int(r[7]),
|
|
665
|
+
"evidence": str(r[8]),
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
issues: list[SchemaDriftIssue] = []
|
|
669
|
+
fields_by_name = {f.name.lower(): f for f in fields}
|
|
670
|
+
|
|
671
|
+
# 4. Check for MISSING_REQUIRED_COLUMN in schema
|
|
672
|
+
for c_lower, c_spec in cols_by_name.items():
|
|
673
|
+
if c_lower in _IGNORED_AUTO_COLUMNS or c_spec["is_primary_key"]:
|
|
674
|
+
continue
|
|
675
|
+
# Column is required if nullable is False and no default is configured
|
|
676
|
+
col_required = (not c_spec["nullable"]) and (c_spec["default_value"] is None)
|
|
677
|
+
if col_required and c_lower not in fields_by_name:
|
|
678
|
+
issues.append(
|
|
679
|
+
SchemaDriftIssue(
|
|
680
|
+
drift_type=DriftType.MISSING_REQUIRED_COLUMN.value,
|
|
681
|
+
field_name=c_spec["column_name"],
|
|
682
|
+
column_name=c_spec["column_name"],
|
|
683
|
+
severity=DriftSeverity.CRITICAL.value,
|
|
684
|
+
message=(
|
|
685
|
+
f"Database column '{resolved_table}.{c_spec['column_name']}' is NOT NULL with no default, "
|
|
686
|
+
f"but input schema '{resolved_schema_name}' does not define this field."
|
|
687
|
+
),
|
|
688
|
+
schema_field=None,
|
|
689
|
+
db_column=c_spec,
|
|
690
|
+
evidence=c_spec["evidence"],
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# 5. Check schema fields against matched columns
|
|
695
|
+
for f_lower, f_spec in fields_by_name.items():
|
|
696
|
+
if f_lower in _IGNORED_SCHEMA_FIELDS:
|
|
697
|
+
continue
|
|
698
|
+
|
|
699
|
+
matched_col = cols_by_name.get(f_lower)
|
|
700
|
+
if not matched_col:
|
|
701
|
+
issues.append(
|
|
702
|
+
SchemaDriftIssue(
|
|
703
|
+
drift_type=DriftType.UNUSED_SCHEMA_FIELD.value,
|
|
704
|
+
field_name=f_spec.name,
|
|
705
|
+
column_name=None,
|
|
706
|
+
severity=DriftSeverity.INFO.value,
|
|
707
|
+
message=(
|
|
708
|
+
f"Schema field '{resolved_schema_name}.{f_spec.name}' is accepted by route, "
|
|
709
|
+
f"but no matching column exists in destination table '{resolved_table}'."
|
|
710
|
+
),
|
|
711
|
+
schema_field=f_spec.as_dict(),
|
|
712
|
+
db_column=None,
|
|
713
|
+
evidence=f"{f_spec.file_path}:{f_spec.line}",
|
|
714
|
+
)
|
|
715
|
+
)
|
|
716
|
+
continue
|
|
717
|
+
|
|
718
|
+
# NULLABILITY_MISMATCH: schema is nullable/optional, but DB rejects NULLs without default
|
|
719
|
+
db_rejects_null = (not matched_col["nullable"]) and (matched_col["default_value"] is None)
|
|
720
|
+
if f_spec.nullable and db_rejects_null:
|
|
721
|
+
issues.append(
|
|
722
|
+
SchemaDriftIssue(
|
|
723
|
+
drift_type=DriftType.NULLABILITY_MISMATCH.value,
|
|
724
|
+
field_name=f_spec.name,
|
|
725
|
+
column_name=matched_col["column_name"],
|
|
726
|
+
severity=DriftSeverity.CRITICAL.value,
|
|
727
|
+
message=(
|
|
728
|
+
f"Schema field '{f_spec.name}' allows None/null, but DB column '{matched_col['column_name']}' "
|
|
729
|
+
f"is NOT NULL with no default value."
|
|
730
|
+
),
|
|
731
|
+
schema_field=f_spec.as_dict(),
|
|
732
|
+
db_column=matched_col,
|
|
733
|
+
evidence=f"Schema: {f_spec.file_path}:{f_spec.line} | DB: {matched_col['evidence']}",
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# TYPE_INCOMPATIBILITY
|
|
738
|
+
if not _are_types_compatible(f_spec.type_name, matched_col["data_type"]):
|
|
739
|
+
issues.append(
|
|
740
|
+
SchemaDriftIssue(
|
|
741
|
+
drift_type=DriftType.TYPE_INCOMPATIBILITY.value,
|
|
742
|
+
field_name=f_spec.name,
|
|
743
|
+
column_name=matched_col["column_name"],
|
|
744
|
+
severity=DriftSeverity.CRITICAL.value,
|
|
745
|
+
message=(
|
|
746
|
+
f"Type mismatch: Schema field '{f_spec.name}' type '{f_spec.type_name}' "
|
|
747
|
+
f"is incompatible with database column type '{matched_col['data_type']}'."
|
|
748
|
+
),
|
|
749
|
+
schema_field=f_spec.as_dict(),
|
|
750
|
+
db_column=matched_col,
|
|
751
|
+
evidence=f"Schema: {f_spec.type_name} | DB: {matched_col['data_type']}",
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# LENGTH_CONSTRAINT_DRIFT
|
|
756
|
+
col_max_len = _extract_db_varchar_length(matched_col["data_type"])
|
|
757
|
+
if col_max_len is not None and f_spec.max_length is not None and f_spec.max_length > col_max_len:
|
|
758
|
+
issues.append(
|
|
759
|
+
SchemaDriftIssue(
|
|
760
|
+
drift_type=DriftType.LENGTH_CONSTRAINT_DRIFT.value,
|
|
761
|
+
field_name=f_spec.name,
|
|
762
|
+
column_name=matched_col["column_name"],
|
|
763
|
+
severity=DriftSeverity.WARNING.value,
|
|
764
|
+
message=(
|
|
765
|
+
f"Length constraint drift: Schema allows max_length={f_spec.max_length}, "
|
|
766
|
+
f"but DB column '{matched_col['column_name']}' is restricted to {col_max_len} characters."
|
|
767
|
+
),
|
|
768
|
+
schema_field=f_spec.as_dict(),
|
|
769
|
+
db_column=matched_col,
|
|
770
|
+
evidence=f"Schema max_length={f_spec.max_length} vs DB {matched_col['data_type']}",
|
|
771
|
+
)
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
# 6. Reconcile runtime SQL execution against destination database table columns
|
|
775
|
+
runtime_cols_written: set[str] = set()
|
|
776
|
+
last_sql_sample = ""
|
|
777
|
+
for r in rt_sql_rows:
|
|
778
|
+
op = str(r[0] or "").upper()
|
|
779
|
+
sql_txt = str(r[3] or "")
|
|
780
|
+
if not last_sql_sample and sql_txt:
|
|
781
|
+
last_sql_sample = sql_txt
|
|
782
|
+
try:
|
|
783
|
+
c_list = [str(x) for x in json.loads(r[2] or "[]")]
|
|
784
|
+
except Exception:
|
|
785
|
+
c_list = []
|
|
786
|
+
for c in c_list:
|
|
787
|
+
if c:
|
|
788
|
+
runtime_cols_written.add(c.lower())
|
|
789
|
+
|
|
790
|
+
# If operation is INSERT: check if any required NOT NULL column in DB was omitted
|
|
791
|
+
if op == "INSERT" and c_list:
|
|
792
|
+
inserted_lower = {c.lower() for c in c_list}
|
|
793
|
+
for c_lower, c_spec in cols_by_name.items():
|
|
794
|
+
if c_lower in _IGNORED_AUTO_COLUMNS or c_spec["is_primary_key"]:
|
|
795
|
+
continue
|
|
796
|
+
col_required = (not c_spec["nullable"]) and (c_spec["default_value"] is None)
|
|
797
|
+
if col_required and c_lower not in inserted_lower:
|
|
798
|
+
if not any(i.drift_type == DriftType.RUNTIME_MISSING_REQUIRED_COLUMN.value and i.column_name == c_spec["column_name"] for i in issues):
|
|
799
|
+
issues.append(
|
|
800
|
+
SchemaDriftIssue(
|
|
801
|
+
drift_type=DriftType.RUNTIME_MISSING_REQUIRED_COLUMN.value,
|
|
802
|
+
field_name=c_spec["column_name"],
|
|
803
|
+
column_name=c_spec["column_name"],
|
|
804
|
+
severity=DriftSeverity.CRITICAL.value,
|
|
805
|
+
message=(
|
|
806
|
+
f"[RUNTIME_OBSERVED] Executed query '{sql_txt}' omitted required NOT NULL column "
|
|
807
|
+
f"'{resolved_table}.{c_spec['column_name']}'."
|
|
808
|
+
),
|
|
809
|
+
schema_field=None,
|
|
810
|
+
db_column=c_spec,
|
|
811
|
+
evidence=f"Runtime SQL: {sql_txt} | DB: {c_spec['evidence']}",
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# Check if query wrote to unknown column not in DB
|
|
816
|
+
for written_c in c_list:
|
|
817
|
+
if written_c.lower() not in cols_by_name:
|
|
818
|
+
if not any(i.drift_type == DriftType.RUNTIME_UNKNOWN_COLUMN_WRITTEN.value and i.field_name == written_c for i in issues):
|
|
819
|
+
issues.append(
|
|
820
|
+
SchemaDriftIssue(
|
|
821
|
+
drift_type=DriftType.RUNTIME_UNKNOWN_COLUMN_WRITTEN.value,
|
|
822
|
+
field_name=written_c,
|
|
823
|
+
column_name=None,
|
|
824
|
+
severity=DriftSeverity.CRITICAL.value,
|
|
825
|
+
message=(
|
|
826
|
+
f"[RUNTIME_OBSERVED] Executed query '{sql_txt}' wrote to unknown column '{written_c}' "
|
|
827
|
+
f"not present in destination table '{resolved_table}'."
|
|
828
|
+
),
|
|
829
|
+
schema_field=None,
|
|
830
|
+
db_column=None,
|
|
831
|
+
evidence=f"Runtime SQL: {sql_txt}",
|
|
832
|
+
)
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
# Deterministic sorting
|
|
836
|
+
severity_order = {DriftSeverity.CRITICAL.value: 0, DriftSeverity.WARNING.value: 1, DriftSeverity.INFO.value: 2}
|
|
837
|
+
sorted_issues = sorted(
|
|
838
|
+
issues,
|
|
839
|
+
key=lambda x: (severity_order.get(x.severity, 99), x.drift_type, x.field_name),
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
runtime_recon = {
|
|
843
|
+
"is_observed": bool(rt_sql_rows),
|
|
844
|
+
"observation_count": len(rt_sql_rows),
|
|
845
|
+
"last_sql_sample": last_sql_sample if last_sql_sample else None,
|
|
846
|
+
"runtime_columns_written": sorted(list(runtime_cols_written)),
|
|
847
|
+
"status": "CONFIRMED_RUNTIME_PATH" if rt_sql_rows else "NOT_OBSERVED_AT_RUNTIME",
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
"status": "ok",
|
|
852
|
+
"route_or_handler": route_or_handler,
|
|
853
|
+
"target_table": resolved_table,
|
|
854
|
+
"schema_name": resolved_schema_name,
|
|
855
|
+
"schema_mode": schema_mode,
|
|
856
|
+
"drift_count": len(sorted_issues),
|
|
857
|
+
"critical_count": sum(1 for i in sorted_issues if i.severity == DriftSeverity.CRITICAL.value),
|
|
858
|
+
"warning_count": sum(1 for i in sorted_issues if i.severity == DriftSeverity.WARNING.value),
|
|
859
|
+
"info_count": sum(1 for i in sorted_issues if i.severity == DriftSeverity.INFO.value),
|
|
860
|
+
"issues": [i.as_dict() for i in sorted_issues],
|
|
861
|
+
"runtime_reconciliation": runtime_recon,
|
|
862
|
+
}
|