codegraph-engine 3.0.0__2-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.
Files changed (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +868 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3651 @@
1
+ """Deterministic Database, ORM, Query, and Migration Extractor (Phases 1-7).
2
+
3
+ Inspects Python, JS/TS, SQL, and Prisma files without executing repository code.
4
+ All extracted evidence and SQL strings pass through secret redaction before emission.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from codegraph.evidence_contract import PARSER_VERSION, RelationshipRecord
14
+ from codegraph.indexing.models import normalize_module
15
+ from codegraph.security.redaction import (
16
+ detect_env_variable_reads,
17
+ normalize_and_redact_sql,
18
+ parse_safe_connection_metadata,
19
+ redact_secrets,
20
+ )
21
+
22
+ from .models import (
23
+ DatabaseEntity,
24
+ DatabaseEntityKind,
25
+ DatabaseQueryFact,
26
+ MigrationFact,
27
+ build_db_canonical_id,
28
+ normalize_dialect,
29
+ normalize_schema_name,
30
+ )
31
+
32
+
33
+ def _camel_to_snake(name: str) -> str:
34
+ s1 = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
35
+ return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
36
+
37
+
38
+ def _pluralize_table(snake_name: str) -> str:
39
+ if not snake_name:
40
+ return "unknown"
41
+ if snake_name.endswith("y") and len(snake_name) > 1 and snake_name[-2] not in "aeiou":
42
+ return snake_name[:-1] + "ies"
43
+ if snake_name.endswith("s"):
44
+ return snake_name
45
+ return snake_name + "s"
46
+
47
+
48
+ @dataclass
49
+ class DatabaseExtractionResult:
50
+ """Container for all database facts extracted from a single file or repository pass."""
51
+
52
+ entities: list[DatabaseEntity] = field(default_factory=list)
53
+ queries: list[DatabaseQueryFact] = field(default_factory=list)
54
+ migrations: list[MigrationFact] = field(default_factory=list)
55
+ edges: list[RelationshipRecord] = field(default_factory=list)
56
+ detected_dialects: set[str] = field(default_factory=set)
57
+ env_reads: list[dict[str, Any]] = field(default_factory=list)
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # SQL Parsing Helpers (Deterministic, Non-Executing)
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _SQL_KEYWORD_BLACKLIST: frozenset[str] = frozenset({
65
+ "select",
66
+ "from",
67
+ "where",
68
+ "join",
69
+ "inner",
70
+ "left",
71
+ "right",
72
+ "full",
73
+ "outer",
74
+ "cross",
75
+ "on",
76
+ "group",
77
+ "order",
78
+ "by",
79
+ "having",
80
+ "limit",
81
+ "offset",
82
+ "values",
83
+ "set",
84
+ "into",
85
+ "as",
86
+ "and",
87
+ "or",
88
+ "not",
89
+ "null",
90
+ "true",
91
+ "false",
92
+ "distinct",
93
+ "case",
94
+ "when",
95
+ "then",
96
+ "else",
97
+ "end",
98
+ "exists",
99
+ "in",
100
+ "is",
101
+ "table",
102
+ "if",
103
+ "only",
104
+ "returning",
105
+ "dual",
106
+ })
107
+
108
+
109
+ def parse_sql_statement(sql: str) -> list[dict[str, Any]]:
110
+ """Deterministically parse a static SQL string into operations, tables, and columns."""
111
+ results: list[dict[str, Any]] = []
112
+ if not sql or not sql.strip():
113
+ return results
114
+
115
+ # Strip SQL comments
116
+ cleaned = re.sub(r"--[^\n]*", " ", sql)
117
+ cleaned = re.sub(r"/\*[\s\S]*?\*/", " ", cleaned)
118
+ norm_sql = normalize_and_redact_sql(cleaned)
119
+
120
+ # Split multiple statements on ';' safely
121
+ statements = [s.strip() for s in cleaned.split(";") if s.strip()]
122
+ for stmt in statements:
123
+ stmt_collapse = re.sub(r"\s+", " ", stmt).strip()
124
+ upper = stmt_collapse.upper()
125
+
126
+ # 1. CREATE TABLE [IF NOT EXISTS] [schema.]table ( ... )
127
+ m_create = re.match(
128
+ r"(?i)^CREATE\s+(?:TEMP(?:ORARY)?\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z0-9_.\"`]+)\s*\(([\s\S]+)\)",
129
+ stmt_collapse,
130
+ )
131
+ if m_create:
132
+ raw_tbl = m_create.group(1).strip('"`')
133
+ tbl_name = raw_tbl.split(".")[-1].lower()
134
+ body = m_create.group(2)
135
+ cols: list[dict[str, Any]] = []
136
+ fks: list[dict[str, str]] = []
137
+ for part in _split_sql_Parens(body):
138
+ p_strip = part.strip()
139
+ if not p_strip:
140
+ continue
141
+ p_up = p_strip.upper()
142
+ if p_up.startswith("FOREIGN KEY"):
143
+ fk_m = re.search(
144
+ r"(?i)FOREIGN\s+KEY\s*\(\s*([A-Za-z0-9_\"`]+)\s*\)\s*REFERENCES\s+([A-Za-z0-9_.\"`]+)\s*\(\s*([A-Za-z0-9_\"`]+)\s*\)",
145
+ p_strip,
146
+ )
147
+ if fk_m:
148
+ fks.append({
149
+ "column": fk_m.group(1).strip('"`').lower(),
150
+ "target_table": fk_m.group(2).strip('"`').split(".")[-1].lower(),
151
+ "target_column": fk_m.group(3).strip('"`').lower(),
152
+ })
153
+ continue
154
+ if p_up.startswith(("PRIMARY KEY", "CONSTRAINT", "UNIQUE", "CHECK", "INDEX", "KEY")):
155
+ continue
156
+ col_tokens = p_strip.split()
157
+ if len(col_tokens) >= 2:
158
+ c_name = col_tokens[0].strip('"`').lower()
159
+ c_type = col_tokens[1].upper()
160
+ is_pk = "PRIMARY KEY" in p_up
161
+ is_uq = "UNIQUE" in p_up
162
+ nullable = "NOT NULL" not in p_up and not is_pk
163
+ inline_fk = re.search(
164
+ r"(?i)REFERENCES\s+([A-Za-z0-9_.\"`]+)\s*(?:\(\s*([A-Za-z0-9_\"`]+)\s*\))?",
165
+ p_strip,
166
+ )
167
+ if inline_fk:
168
+ fks.append({
169
+ "column": c_name,
170
+ "target_table": inline_fk.group(1).strip('"`').split(".")[-1].lower(),
171
+ "target_column": (inline_fk.group(2) or "id").strip('"`').lower(),
172
+ })
173
+ cols.append({
174
+ "name": c_name,
175
+ "data_type": c_type,
176
+ "is_primary_key": is_pk,
177
+ "is_unique": is_uq,
178
+ "nullable": nullable,
179
+ })
180
+ results.append({
181
+ "operation": "CREATE_TABLE",
182
+ "relationship": "MIGRATES_TABLE",
183
+ "tables": [tbl_name],
184
+ "columns": [c["name"] for c in cols],
185
+ "column_defs": cols,
186
+ "foreign_keys": fks,
187
+ "normalized_sql": norm_sql,
188
+ })
189
+ continue
190
+
191
+ # 2. CREATE VIEW
192
+ m_view = re.match(
193
+ r"(?i)^CREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z0-9_.\"`]+)\s+AS\s+([\s\S]+)",
194
+ stmt_collapse,
195
+ )
196
+ if m_view:
197
+ view_name = m_view.group(1).strip('"`').split(".")[-1].lower()
198
+ results.append({
199
+ "operation": "CREATE_VIEW",
200
+ "relationship": "DEFINES",
201
+ "view_name": view_name,
202
+ "tables": [view_name],
203
+ "columns": [],
204
+ "normalized_sql": norm_sql,
205
+ })
206
+ continue
207
+
208
+ # 3. CREATE SEQUENCE
209
+ m_seq = re.match(
210
+ r"(?i)^CREATE\s+SEQUENCE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z0-9_.\"`]+)",
211
+ stmt_collapse,
212
+ )
213
+ if m_seq:
214
+ seq_name = m_seq.group(1).strip('"`').split(".")[-1].lower()
215
+ results.append({
216
+ "operation": "CREATE_SEQUENCE",
217
+ "relationship": "DEFINES",
218
+ "sequence_name": seq_name,
219
+ "tables": [],
220
+ "columns": [],
221
+ "normalized_sql": norm_sql,
222
+ })
223
+ continue
224
+
225
+ # 4. ALTER TABLE
226
+ m_alter = re.match(
227
+ r"(?i)^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([A-Za-z0-9_.\"`]+)\s+([\s\S]+)",
228
+ stmt_collapse,
229
+ )
230
+ if m_alter:
231
+ tbl_name = m_alter.group(1).strip('"`').split(".")[-1].lower()
232
+ action = m_alter.group(2)
233
+ added_cols: list[str] = []
234
+ for m_add in re.finditer(r"(?i)ADD\s+(?:COLUMN\s+)?([A-Za-z0-9_\"`]+)", action):
235
+ col = m_add.group(1).strip('"`').lower()
236
+ if col not in ("constraint", "foreign", "primary", "unique", "check", "index"):
237
+ added_cols.append(col)
238
+ results.append({
239
+ "operation": "ALTER_TABLE",
240
+ "relationship": "MIGRATES_TABLE",
241
+ "tables": [tbl_name],
242
+ "columns": added_cols,
243
+ "normalized_sql": norm_sql,
244
+ })
245
+ continue
246
+
247
+ # 5. INSERT INTO [schema.]table [(col1, col2)] VALUES ...
248
+ m_ins = re.search(
249
+ r"(?i)\bINSERT\s+(?:OR\s+\w+\s+)?INTO\s+([A-Za-z0-9_.\"`]+)\s*(?:\(\s*([^)]+)\s*\))?",
250
+ stmt_collapse,
251
+ )
252
+ if m_ins:
253
+ tbl_name = m_ins.group(1).strip('"`').split(".")[-1].lower()
254
+ if tbl_name not in _SQL_KEYWORD_BLACKLIST:
255
+ cols_raw = m_ins.group(2) or ""
256
+ col_names = [
257
+ c.strip().strip('"`').lower()
258
+ for c in cols_raw.split(",")
259
+ if c.strip() and re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", c.strip().strip('"`'))
260
+ ]
261
+ results.append({
262
+ "operation": "INSERT",
263
+ "relationship": "WRITES_TABLE",
264
+ "tables": [tbl_name],
265
+ "columns": col_names,
266
+ "normalized_sql": norm_sql,
267
+ })
268
+ continue
269
+
270
+ # 6. UPDATE [schema.]table SET col1 = ..., col2 = ... [WHERE ...]
271
+ m_upd = re.search(
272
+ r"(?i)\bUPDATE\s+([A-Za-z0-9_.\"`]+)\s+SET\s+([\s\S]+?)(?:\bWHERE\b|\bRETURNING\b|$)",
273
+ stmt_collapse,
274
+ )
275
+ if m_upd:
276
+ tbl_name = m_upd.group(1).strip('"`').split(".")[-1].lower()
277
+ if tbl_name not in _SQL_KEYWORD_BLACKLIST:
278
+ set_clause = m_upd.group(2)
279
+ write_cols = [
280
+ m.group(1).strip('"`').lower()
281
+ for m in re.finditer(r"([A-Za-z_][A-Za-z0-9_]*)\s*=", set_clause)
282
+ ]
283
+ where_match = re.search(r"(?i)\bWHERE\s+([\s\S]+)$", stmt_collapse)
284
+ read_cols: list[str] = []
285
+ if where_match:
286
+ for wm in re.finditer(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|<|>|LIKE|IN|IS)", where_match.group(1)):
287
+ cname = wm.group(1).lower()
288
+ if cname not in _SQL_KEYWORD_BLACKLIST:
289
+ read_cols.append(cname)
290
+ results.append({
291
+ "operation": "UPDATE",
292
+ "relationship": "WRITES_TABLE",
293
+ "tables": [tbl_name],
294
+ "columns": write_cols,
295
+ "read_columns": read_cols,
296
+ "normalized_sql": norm_sql,
297
+ })
298
+ continue
299
+
300
+ # 7. DELETE FROM [schema.]table [WHERE ...]
301
+ m_del = re.search(
302
+ r"(?i)\bDELETE\s+FROM\s+([A-Za-z0-9_.\"`]+)",
303
+ stmt_collapse,
304
+ )
305
+ if m_del:
306
+ tbl_name = m_del.group(1).strip('"`').split(".")[-1].lower()
307
+ if tbl_name not in _SQL_KEYWORD_BLACKLIST:
308
+ results.append({
309
+ "operation": "DELETE",
310
+ "relationship": "WRITES_TABLE",
311
+ "tables": [tbl_name],
312
+ "columns": [],
313
+ "normalized_sql": norm_sql,
314
+ })
315
+ continue
316
+
317
+ # 8. SELECT ... FROM table [JOIN table2 ...]
318
+ if "SELECT " in upper and " FROM " in upper:
319
+ tables_found: list[str] = []
320
+ for tm in re.finditer(
321
+ r"(?i)\b(?:FROM|JOIN)\s+([A-Za-z_][A-Za-z0-9_.]*)",
322
+ stmt_collapse,
323
+ ):
324
+ t_cand = tm.group(1).split(".")[-1].lower()
325
+ if t_cand not in _SQL_KEYWORD_BLACKLIST and t_cand not in tables_found:
326
+ tables_found.append(t_cand)
327
+ if tables_found:
328
+ sel_match = re.search(r"(?i)\bSELECT\s+(?:DISTINCT\s+)?([\s\S]+?)\s+\bFROM\b", stmt_collapse)
329
+ sel_cols: list[str] = []
330
+ if sel_match:
331
+ sel_part = sel_match.group(1).strip()
332
+ if sel_part != "*":
333
+ for item in sel_part.split(","):
334
+ tok = item.strip().split(" ")[0].split(".")[-1].strip('"`').lower()
335
+ if re.match(r"^[a-z_][a-z0-9_]*$", tok) and tok not in _SQL_KEYWORD_BLACKLIST:
336
+ sel_cols.append(tok)
337
+ where_match = re.search(r"(?i)\bWHERE\s+([\s\S]+?)(?:\bGROUP\b|\bORDER\b|\bLIMIT\b|$)", stmt_collapse)
338
+ if where_match:
339
+ for wm in re.finditer(r"\b(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|<|>|LIKE|IN|IS)", where_match.group(1)):
340
+ cname = wm.group(1).lower()
341
+ if cname not in _SQL_KEYWORD_BLACKLIST and cname not in sel_cols:
342
+ sel_cols.append(cname)
343
+ results.append({
344
+ "operation": "SELECT",
345
+ "relationship": "READS_TABLE",
346
+ "tables": tables_found,
347
+ "columns": sel_cols,
348
+ "normalized_sql": norm_sql,
349
+ })
350
+
351
+ return results
352
+
353
+
354
+ def _split_sql_Parens(body: str) -> list[str]:
355
+ parts: list[str] = []
356
+ cur: list[str] = []
357
+ depth = 0
358
+ for ch in body:
359
+ if ch == "(":
360
+ depth += 1
361
+ cur.append(ch)
362
+ elif ch == ")":
363
+ depth = max(0, depth - 1)
364
+ cur.append(ch)
365
+ elif ch == "," and depth == 0:
366
+ parts.append("".join(cur))
367
+ cur = []
368
+ else:
369
+ cur.append(ch)
370
+ if cur:
371
+ parts.append("".join(cur))
372
+ return parts
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Python AST Extractor for ORMs, Queries, and Migrations
377
+ # ---------------------------------------------------------------------------
378
+
379
+ _DJANGO_FIELD_TYPES: frozenset[str] = frozenset({
380
+ "CharField",
381
+ "TextField",
382
+ "IntegerField",
383
+ "BigIntegerField",
384
+ "SmallIntegerField",
385
+ "PositiveIntegerField",
386
+ "FloatField",
387
+ "DecimalField",
388
+ "BooleanField",
389
+ "DateField",
390
+ "DateTimeField",
391
+ "TimeField",
392
+ "EmailField",
393
+ "URLField",
394
+ "UUIDField",
395
+ "SlugField",
396
+ "JSONField",
397
+ "BinaryField",
398
+ "FileField",
399
+ "ImageField",
400
+ "AutoField",
401
+ "BigAutoField",
402
+ "SmallAutoField",
403
+ "ForeignKey",
404
+ "OneToOneField",
405
+ "ManyToManyField",
406
+ })
407
+
408
+
409
+ def _ast_call_name(node: ast.Call) -> str:
410
+ if isinstance(node.func, ast.Name):
411
+ return node.func.id
412
+ if isinstance(node.func, ast.Attribute):
413
+ if isinstance(node.func.value, ast.Name):
414
+ return f"{node.func.value.id}.{node.func.attr}"
415
+ return node.func.attr
416
+ return ""
417
+
418
+
419
+ def _ast_const_str(node: ast.AST | None) -> str | None:
420
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
421
+ return node.value
422
+ return None
423
+
424
+
425
+ def _ast_const_bool(node: ast.AST | None) -> bool | None:
426
+ if isinstance(node, ast.Constant) and isinstance(node.value, bool):
427
+ return node.value
428
+ return None
429
+
430
+
431
+ def _get_kwarg(call: ast.Call, name: str) -> ast.AST | None:
432
+ for kw in call.keywords:
433
+ if kw.arg == name:
434
+ return kw.value
435
+ return None
436
+
437
+
438
+ def detect_file_dialects(content: str, file_path: str) -> set[str]:
439
+ """Detect database dialects referenced in a file from imports and connection URLs."""
440
+ dialects: set[str] = set()
441
+ lower = content.lower()
442
+ if any(k in lower for k in ("psycopg", "asyncpg", "postgresql://", "postgres://", "django.db.backends.postgresql")):
443
+ dialects.add("postgres")
444
+ if any(k in lower for k in ("sqlite3", "aiosqlite", "sqlite://", "django.db.backends.sqlite3")):
445
+ dialects.add("sqlite")
446
+ if any(k in lower for k in ("pymysql", "mysqlclient", "mysql://", "mysql+pymysql://", "django.db.backends.mysql")):
447
+ dialects.add("mysql")
448
+ if any(k in lower for k in ("pymongo", "motor", "mongodb://", "mongodb+srv://")):
449
+ dialects.add("mongodb")
450
+ if file_path.endswith(".prisma"):
451
+ for m in re.finditer(r'provider\s*=\s*"([^"]+)"', content):
452
+ prov = m.group(1).lower()
453
+ if prov in ("postgresql", "postgres", "sqlite", "mysql", "mongodb", "sqlserver"):
454
+ dialects.add(normalize_dialect(prov))
455
+ return dialects
456
+
457
+
458
+ _PYTHON_DB_TOKENS: tuple[str, ...] = (
459
+ "execute",
460
+ "fetch",
461
+ ".raw(",
462
+ "query",
463
+ "select(",
464
+ "insert(",
465
+ "update(",
466
+ "delete(",
467
+ ".add(",
468
+ ".add_all(",
469
+ ".merge(",
470
+ ".objects",
471
+ "__tablename__",
472
+ "db_table",
473
+ "DeclarativeBase",
474
+ "SQLModel",
475
+ "Model",
476
+ "Base",
477
+ "AsyncAttrs",
478
+ "table=",
479
+ "Table(",
480
+ "Sequence(",
481
+ "create_engine",
482
+ "alembic",
483
+ "Migration",
484
+ )
485
+
486
+ _PYTHON_ORM_MODEL_TOKENS: tuple[str, ...] = (
487
+ "__tablename__",
488
+ "db_table",
489
+ "DeclarativeBase",
490
+ "SQLModel",
491
+ "Model",
492
+ "Base",
493
+ "AsyncAttrs",
494
+ "table=",
495
+ )
496
+
497
+ _PYTHON_QUERY_TOKENS: tuple[str, ...] = (
498
+ "execute",
499
+ "fetch",
500
+ ".raw(",
501
+ "query",
502
+ "select(",
503
+ "insert(",
504
+ "update(",
505
+ "delete(",
506
+ ".add(",
507
+ ".add_all(",
508
+ ".merge(",
509
+ ".objects",
510
+ )
511
+
512
+
513
+ def has_potential_orm_models(content: str, file_path: str, language: str = "python") -> bool:
514
+ """Return True if a file may define ORMModel entities needed for Pass 1 cross-file mapping."""
515
+ if not content:
516
+ return False
517
+ if file_path.endswith(".prisma") or language == "prisma":
518
+ return "model " in content
519
+ if file_path.endswith(".sql") or language == "sql":
520
+ return False
521
+ if language == "python" or file_path.endswith(".py"):
522
+ return any(tok in content for tok in _PYTHON_ORM_MODEL_TOKENS)
523
+ if language in ("javascript", "typescript") or file_path.endswith((".js", ".jsx", ".ts", ".tsx")):
524
+ return any(tok in content for tok in ("Entity", "define(", ".init(", "model(", "Schema(", "pgTable", "mysqlTable", "sqliteTable"))
525
+ return False
526
+
527
+
528
+ def has_potential_database_activity(content: str, file_path: str, language: str = "python") -> bool:
529
+ """Return True if a file may contain DB entities, queries, migrations, or env variable reads."""
530
+ if not content:
531
+ return False
532
+ if file_path.endswith((".sql", ".prisma")) or language in ("sql", "prisma"):
533
+ return True
534
+ if language == "python" or file_path.endswith(".py"):
535
+ return (
536
+ "getenv" in content
537
+ or "environ" in content
538
+ or any(tok in content for tok in _PYTHON_DB_TOKENS)
539
+ )
540
+ if language in ("javascript", "typescript") or file_path.endswith((".js", ".jsx", ".ts", ".tsx")):
541
+ return True
542
+ return False
543
+
544
+
545
+ def extract_database_from_file(
546
+ content: str,
547
+ file_path: str,
548
+ language: str = "python",
549
+ repo_dialect: str = "UNKNOWN",
550
+ known_model_tables: dict[str, str] | None = None,
551
+ index_generation: int = 1,
552
+ tree: ast.AST | None = None,
553
+ ) -> DatabaseExtractionResult:
554
+ """Extract database entities, ORM mappings, queries, migrations, and edges from a file."""
555
+ res = DatabaseExtractionResult()
556
+ if not content:
557
+ return res
558
+
559
+ module_name = normalize_module(file_path, language)
560
+ file_dialects = detect_file_dialects(content, file_path)
561
+ res.detected_dialects.update(file_dialects)
562
+
563
+ effective_dialect = repo_dialect
564
+ if effective_dialect == "UNKNOWN":
565
+ if len(file_dialects) == 1:
566
+ effective_dialect = next(iter(file_dialects))
567
+ elif len(file_dialects) > 1:
568
+ effective_dialect = "AMBIGUOUS"
569
+
570
+ effective_schema = normalize_schema_name(None, effective_dialect)
571
+ model_to_table: dict[str, str] = dict(known_model_tables or {})
572
+
573
+ is_py = language == "python" or file_path.endswith(".py")
574
+ has_py_env = is_py and ("getenv" in content or "environ" in content)
575
+ has_py_db = is_py and any(tok in content for tok in _PYTHON_DB_TOKENS)
576
+ if is_py and tree is None and (has_py_env or has_py_db):
577
+ try:
578
+ tree = ast.parse(content)
579
+ except SyntaxError:
580
+ tree = None
581
+
582
+ # Track environment variable reads safely
583
+ env_reads = detect_env_variable_reads(content, file_path, module_name, tree=tree)
584
+ res.env_reads.extend(env_reads)
585
+ for er in env_reads:
586
+ res.edges.append(
587
+ RelationshipRecord(
588
+ source=str(er["source"]),
589
+ target=str(er["target"]),
590
+ relationship="READS_ENV",
591
+ evidence_class="AST_VERIFIED",
592
+ confidence="HIGH",
593
+ file=file_path,
594
+ start_line=int(er["line"]),
595
+ end_line=int(er["line"]),
596
+ evidence=redact_secrets(str(er["evidence"])),
597
+ status="FACT",
598
+ parser_version=PARSER_VERSION,
599
+ index_generation=index_generation,
600
+ )
601
+ )
602
+
603
+ if file_path.endswith(".sql") or language == "sql":
604
+ _extract_sql_file(content, file_path, effective_dialect, effective_schema, index_generation, res)
605
+ return res
606
+
607
+ if file_path.endswith(".prisma") or language == "prisma":
608
+ _extract_prisma_schema(content, file_path, effective_dialect, effective_schema, index_generation, res)
609
+ return res
610
+
611
+ if language in ("javascript", "typescript") or file_path.endswith((".js", ".jsx", ".ts", ".tsx")):
612
+ _extract_js_ts_database(
613
+ content,
614
+ file_path,
615
+ module_name,
616
+ effective_dialect,
617
+ effective_schema,
618
+ model_to_table,
619
+ index_generation,
620
+ res,
621
+ )
622
+ return res
623
+
624
+ if is_py and has_py_db:
625
+ _extract_python_database(
626
+ content,
627
+ file_path,
628
+ module_name,
629
+ effective_dialect,
630
+ effective_schema,
631
+ model_to_table,
632
+ index_generation,
633
+ res,
634
+ tree=tree,
635
+ )
636
+
637
+ return res
638
+
639
+
640
+ def _extract_python_database(
641
+ content: str,
642
+ file_path: str,
643
+ module_name: str,
644
+ dialect: str,
645
+ schema: str,
646
+ model_to_table: dict[str, str],
647
+ index_generation: int,
648
+ res: DatabaseExtractionResult,
649
+ tree: ast.AST | None = None,
650
+ ) -> None:
651
+ if tree is None:
652
+ if not any(tok in content for tok in _PYTHON_DB_TOKENS):
653
+ return
654
+ try:
655
+ tree = ast.parse(content)
656
+ except SyntaxError:
657
+ return
658
+
659
+ lines = content.splitlines()
660
+
661
+ def _line_snippet(lineno: int) -> str:
662
+ if 1 <= lineno <= len(lines):
663
+ return redact_secrets(lines[lineno - 1].strip())
664
+ return ""
665
+
666
+ # Check if this is an Alembic or Django migration file
667
+ is_alembic = ("from alembic import op" in content or "import alembic" in content or "op.create_table" in content)
668
+ is_django_migration = ("migrations.Migration" in content and "operations" in content)
669
+
670
+ if is_alembic and isinstance(tree, ast.Module):
671
+ _extract_alembic_migration(tree, content, file_path, module_name, dialect, schema, index_generation, res)
672
+
673
+ if is_django_migration and isinstance(tree, ast.Module):
674
+ _extract_django_migration(tree, content, file_path, module_name, dialect, schema, index_generation, res)
675
+
676
+ # First pass over module-level SQLAlchemy Core `Table("name", metadata, Column(...))`
677
+ # and `Sequence("name")` and `create_engine(...)`
678
+ for node in getattr(tree, "body", ()):
679
+ if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
680
+ cname = _ast_call_name(node.value)
681
+ if cname in ("Table", "sa.Table", "db.Table") and node.value.args:
682
+ tbl_name = _ast_const_str(node.value.args[0])
683
+ if tbl_name:
684
+ tbl_clean = tbl_name.strip().lower()
685
+ tbl_cid = build_db_canonical_id(
686
+ DatabaseEntityKind.TABLE,
687
+ table=tbl_clean,
688
+ dialect=dialect,
689
+ schema=schema,
690
+ )
691
+ res.entities.append(
692
+ DatabaseEntity(
693
+ canonical_id=tbl_cid,
694
+ kind=DatabaseEntityKind.TABLE.value,
695
+ name=tbl_clean,
696
+ dialect=dialect,
697
+ schema_name=schema,
698
+ table_name=tbl_clean,
699
+ framework="sqlalchemy",
700
+ file_path=file_path,
701
+ start_line=node.lineno,
702
+ end_line=getattr(node, "end_lineno", node.lineno),
703
+ evidence=_line_snippet(node.lineno),
704
+ metadata={"association_table": True},
705
+ )
706
+ )
707
+ for arg in node.value.args[1:]:
708
+ if isinstance(arg, ast.Call) and _ast_call_name(arg).endswith("Column") and arg.args:
709
+ col_nm = _ast_const_str(arg.args[0])
710
+ if col_nm:
711
+ _record_sqlalchemy_column(
712
+ col_nm,
713
+ arg,
714
+ tbl_clean,
715
+ tbl_cid,
716
+ None,
717
+ "sqlalchemy",
718
+ dialect,
719
+ schema,
720
+ file_path,
721
+ _line_snippet(arg.lineno),
722
+ index_generation,
723
+ res,
724
+ )
725
+ elif cname in ("Sequence", "sa.Sequence") and node.value.args:
726
+ seq_nm = _ast_const_str(node.value.args[0])
727
+ if seq_nm:
728
+ seq_cid = build_db_canonical_id(
729
+ DatabaseEntityKind.SEQUENCE,
730
+ name=seq_nm,
731
+ dialect=dialect,
732
+ schema=schema,
733
+ )
734
+ res.entities.append(
735
+ DatabaseEntity(
736
+ canonical_id=seq_cid,
737
+ kind=DatabaseEntityKind.SEQUENCE.value,
738
+ name=seq_nm,
739
+ dialect=dialect,
740
+ schema_name=schema,
741
+ framework="sqlalchemy",
742
+ file_path=file_path,
743
+ start_line=node.lineno,
744
+ end_line=getattr(node, "end_lineno", node.lineno),
745
+ evidence=_line_snippet(node.lineno),
746
+ )
747
+ )
748
+ elif cname.endswith("create_engine") and node.value.args:
749
+ url_str = _ast_const_str(node.value.args[0])
750
+ if url_str:
751
+ meta = parse_safe_connection_metadata(url_str)
752
+ prov = normalize_dialect(meta.get("provider"))
753
+ if prov != "UNKNOWN":
754
+ res.detected_dialects.add(prov)
755
+ db_cid = build_db_canonical_id(
756
+ DatabaseEntityKind.DATABASE,
757
+ name=meta.get("database") or prov,
758
+ dialect=prov,
759
+ schema=schema,
760
+ )
761
+ res.entities.append(
762
+ DatabaseEntity(
763
+ canonical_id=db_cid,
764
+ kind=DatabaseEntityKind.DATABASE.value,
765
+ name=meta.get("database") or prov,
766
+ dialect=prov,
767
+ schema_name=schema,
768
+ framework="sqlalchemy",
769
+ file_path=file_path,
770
+ start_line=node.lineno,
771
+ end_line=getattr(node, "end_lineno", node.lineno),
772
+ evidence=_line_snippet(node.lineno),
773
+ metadata=meta,
774
+ )
775
+ )
776
+
777
+ # Second pass: inspect ClassDefs for ORM models (SQLAlchemy, Flask-SQLAlchemy, SQLModel, Django ORM)
778
+ for node in getattr(tree, "body", ()):
779
+ if isinstance(node, ast.ClassDef):
780
+ _extract_orm_class(
781
+ node,
782
+ file_path,
783
+ module_name,
784
+ dialect,
785
+ schema,
786
+ model_to_table,
787
+ _line_snippet,
788
+ index_generation,
789
+ res,
790
+ )
791
+
792
+ # Third pass: inspect functions and methods for ORM & raw SQL queries
793
+ if any(tok in content for tok in _PYTHON_QUERY_TOKENS):
794
+ _extract_python_queries_in_scope(
795
+ tree,
796
+ file_path,
797
+ module_name,
798
+ dialect,
799
+ schema,
800
+ model_to_table,
801
+ _line_snippet,
802
+ index_generation,
803
+ res,
804
+ )
805
+
806
+
807
+ def _extract_orm_class(
808
+ cls_node: ast.ClassDef,
809
+ file_path: str,
810
+ module_name: str,
811
+ dialect: str,
812
+ schema: str,
813
+ model_to_table: dict[str, str],
814
+ line_snippet: Any,
815
+ index_generation: int,
816
+ res: DatabaseExtractionResult,
817
+ ) -> None:
818
+ base_names: list[str] = []
819
+ for b in cls_node.bases:
820
+ if isinstance(b, ast.Name):
821
+ base_names.append(b.id)
822
+ elif isinstance(b, ast.Attribute):
823
+ if isinstance(b.value, ast.Name):
824
+ base_names.append(f"{b.value.id}.{b.attr}")
825
+ else:
826
+ base_names.append(b.attr)
827
+
828
+ is_sqlmodel_table = any(
829
+ kw.arg == "table" and _ast_const_bool(kw.value) is True for kw in cls_node.keywords
830
+ )
831
+
832
+ framework = ""
833
+ if any(b in ("db.Model", "FlaskModel") for b in base_names):
834
+ framework = "flask_sqlalchemy"
835
+ elif any(b in ("models.Model", "Model") for b in base_names) and not is_sqlmodel_table:
836
+ # Distinguish Django models.Model vs SQLAlchemy Base
837
+ if "models.Model" in base_names or any(
838
+ isinstance(stmt, ast.Assign)
839
+ and isinstance(stmt.value, ast.Call)
840
+ and _ast_call_name(stmt.value).split(".")[-1] in _DJANGO_FIELD_TYPES
841
+ for stmt in cls_node.body
842
+ ):
843
+ framework = "django_orm"
844
+ elif "db.Model" in base_names:
845
+ framework = "flask_sqlalchemy"
846
+ if not framework:
847
+ if "SQLModel" in base_names or is_sqlmodel_table:
848
+ framework = "sqlmodel"
849
+ elif any(b in ("Base", "DeclarativeBase", "AsyncAttrs") or b.endswith("Base") for b in base_names):
850
+ framework = "sqlalchemy"
851
+
852
+ # Also detect if class explicitly defines `__tablename__`
853
+ explicit_tablename: str | None = None
854
+ explicit_schema: str | None = None
855
+ django_meta_table: str | None = None
856
+ django_unique_together: list[tuple[str, ...]] = []
857
+ table_args_nodes: list[ast.AST] = []
858
+
859
+ for stmt in cls_node.body:
860
+ if isinstance(stmt, ast.Assign):
861
+ for tgt in stmt.targets:
862
+ if isinstance(tgt, ast.Name) and tgt.id == "__tablename__":
863
+ explicit_tablename = _ast_const_str(stmt.value)
864
+ elif isinstance(tgt, ast.Name) and tgt.id == "__table_args__":
865
+ if isinstance(stmt.value, (ast.Tuple, ast.List)):
866
+ table_args_nodes.extend(stmt.value.elts)
867
+ elif isinstance(stmt.value, ast.Dict):
868
+ table_args_nodes.append(stmt.value)
869
+ elif isinstance(stmt, ast.ClassDef) and stmt.name == "Meta":
870
+ for mstmt in stmt.body:
871
+ if isinstance(mstmt, ast.Assign):
872
+ for mtgt in mstmt.targets:
873
+ if isinstance(mtgt, ast.Name) and mtgt.id == "db_table":
874
+ django_meta_table = _ast_const_str(mstmt.value)
875
+ elif isinstance(mtgt, ast.Name) and mtgt.id == "unique_together":
876
+ if isinstance(mstmt.value, (ast.Tuple, ast.List)):
877
+ for elt in mstmt.value.elts:
878
+ if isinstance(elt, (ast.Tuple, ast.List)):
879
+ cols = tuple(
880
+ s for e in elt.elts if (s := _ast_const_str(e))
881
+ )
882
+ if cols:
883
+ django_unique_together.append(cols)
884
+
885
+ if explicit_tablename and not framework:
886
+ framework = "sqlalchemy"
887
+
888
+ if not framework:
889
+ return
890
+
891
+ # Determine table name
892
+ for tan in table_args_nodes:
893
+ if isinstance(tan, ast.Dict):
894
+ for k, v in zip(tan.keys, tan.values, strict=False):
895
+ if _ast_const_str(k) == "schema":
896
+ explicit_schema = _ast_const_str(v)
897
+
898
+ tbl_schema = normalize_schema_name(explicit_schema, dialect) if explicit_schema else schema
899
+ if explicit_tablename:
900
+ table_name = explicit_tablename.strip().lower()
901
+ elif django_meta_table:
902
+ table_name = django_meta_table.strip().lower()
903
+ elif framework == "django_orm":
904
+ app_prefix = module_name.split(".")[0].lower() if "." in module_name else "app"
905
+ if app_prefix in ("models", "root", "src"):
906
+ table_name = _pluralize_table(_camel_to_snake(cls_node.name))
907
+ else:
908
+ table_name = f"{app_prefix}_{cls_node.name.lower()}"
909
+ else:
910
+ table_name = _camel_to_snake(cls_node.name)
911
+
912
+ model_canonical_id = f"{module_name}.{cls_node.name}"
913
+ model_to_table[cls_node.name] = table_name
914
+ model_to_table[model_canonical_id] = table_name
915
+
916
+ table_cid = build_db_canonical_id(
917
+ DatabaseEntityKind.TABLE,
918
+ table=table_name,
919
+ dialect=dialect,
920
+ schema=tbl_schema,
921
+ )
922
+
923
+ ev_cls = "FRAMEWORK_VERIFIED"
924
+ ev_line = line_snippet(cls_node.lineno)
925
+
926
+ # Emit ORMModel entity and Table entity
927
+ res.entities.append(
928
+ DatabaseEntity(
929
+ canonical_id=model_canonical_id,
930
+ kind=DatabaseEntityKind.ORM_MODEL.value,
931
+ name=cls_node.name,
932
+ dialect=dialect,
933
+ schema_name=tbl_schema,
934
+ table_name=table_name,
935
+ orm_model_id=model_canonical_id,
936
+ framework=framework,
937
+ file_path=file_path,
938
+ start_line=cls_node.lineno,
939
+ end_line=getattr(cls_node, "end_lineno", cls_node.lineno),
940
+ evidence=ev_line,
941
+ confidence="HIGH",
942
+ evidence_class=ev_cls,
943
+ )
944
+ )
945
+ res.entities.append(
946
+ DatabaseEntity(
947
+ canonical_id=table_cid,
948
+ kind=DatabaseEntityKind.TABLE.value,
949
+ name=table_name,
950
+ dialect=dialect,
951
+ schema_name=tbl_schema,
952
+ table_name=table_name,
953
+ orm_model_id=model_canonical_id,
954
+ framework=framework,
955
+ file_path=file_path,
956
+ start_line=cls_node.lineno,
957
+ end_line=getattr(cls_node, "end_lineno", cls_node.lineno),
958
+ evidence=ev_line,
959
+ confidence="HIGH",
960
+ evidence_class=ev_cls,
961
+ )
962
+ )
963
+ res.edges.append(
964
+ RelationshipRecord(
965
+ source=model_canonical_id,
966
+ target=table_cid,
967
+ relationship="MAPS_TO_TABLE",
968
+ evidence_class=ev_cls,
969
+ confidence="HIGH",
970
+ file=file_path,
971
+ start_line=cls_node.lineno,
972
+ end_line=getattr(cls_node, "end_lineno", cls_node.lineno),
973
+ evidence=ev_line,
974
+ status="FACT",
975
+ parser_version=PARSER_VERSION,
976
+ index_generation=index_generation,
977
+ )
978
+ )
979
+
980
+ # Parse __table_args__ constraints & indexes
981
+ for tan in table_args_nodes:
982
+ if isinstance(tan, ast.Call):
983
+ tname = _ast_call_name(tan).split(".")[-1]
984
+ if tname == "UniqueConstraint":
985
+ uq_cols = [s for a in tan.args if (s := _ast_const_str(a))]
986
+ uq_name = _ast_const_str(_get_kwarg(tan, "name")) or f"uq_{table_name}_{'_'.join(uq_cols)}"
987
+ uq_cid = build_db_canonical_id(
988
+ DatabaseEntityKind.UNIQUE_CONSTRAINT,
989
+ table=table_name,
990
+ name=uq_name,
991
+ dialect=dialect,
992
+ schema=tbl_schema,
993
+ )
994
+ res.entities.append(
995
+ DatabaseEntity(
996
+ canonical_id=uq_cid,
997
+ kind=DatabaseEntityKind.UNIQUE_CONSTRAINT.value,
998
+ name=uq_name,
999
+ dialect=dialect,
1000
+ schema_name=tbl_schema,
1001
+ table_name=table_name,
1002
+ framework=framework,
1003
+ file_path=file_path,
1004
+ start_line=tan.lineno,
1005
+ end_line=getattr(tan, "end_lineno", tan.lineno),
1006
+ evidence=line_snippet(tan.lineno),
1007
+ metadata={"columns": uq_cols},
1008
+ )
1009
+ )
1010
+ res.edges.append(
1011
+ RelationshipRecord(
1012
+ source=table_cid,
1013
+ target=uq_cid,
1014
+ relationship="HAS_UNIQUE_CONSTRAINT",
1015
+ evidence_class="AST_VERIFIED",
1016
+ confidence="HIGH",
1017
+ file=file_path,
1018
+ start_line=tan.lineno,
1019
+ end_line=getattr(tan, "end_lineno", tan.lineno),
1020
+ evidence=line_snippet(tan.lineno),
1021
+ status="FACT",
1022
+ parser_version=PARSER_VERSION,
1023
+ index_generation=index_generation,
1024
+ )
1025
+ )
1026
+ elif tname == "CheckConstraint":
1027
+ ck_expr = _ast_const_str(tan.args[0]) if tan.args else ""
1028
+ ck_name = _ast_const_str(_get_kwarg(tan, "name")) or f"ck_{table_name}_{tan.lineno}"
1029
+ ck_cid = build_db_canonical_id(
1030
+ DatabaseEntityKind.CHECK_CONSTRAINT,
1031
+ table=table_name,
1032
+ name=ck_name,
1033
+ dialect=dialect,
1034
+ schema=tbl_schema,
1035
+ )
1036
+ res.entities.append(
1037
+ DatabaseEntity(
1038
+ canonical_id=ck_cid,
1039
+ kind=DatabaseEntityKind.CHECK_CONSTRAINT.value,
1040
+ name=ck_name,
1041
+ dialect=dialect,
1042
+ schema_name=tbl_schema,
1043
+ table_name=table_name,
1044
+ framework=framework,
1045
+ file_path=file_path,
1046
+ start_line=tan.lineno,
1047
+ end_line=getattr(tan, "end_lineno", tan.lineno),
1048
+ evidence=line_snippet(tan.lineno),
1049
+ metadata={"expression": redact_secrets(ck_expr or "")},
1050
+ )
1051
+ )
1052
+ res.edges.append(
1053
+ RelationshipRecord(
1054
+ source=table_cid,
1055
+ target=ck_cid,
1056
+ relationship="HAS_CHECK_CONSTRAINT",
1057
+ evidence_class="AST_VERIFIED",
1058
+ confidence="HIGH",
1059
+ file=file_path,
1060
+ start_line=tan.lineno,
1061
+ end_line=getattr(tan, "end_lineno", tan.lineno),
1062
+ evidence=line_snippet(tan.lineno),
1063
+ status="FACT",
1064
+ parser_version=PARSER_VERSION,
1065
+ index_generation=index_generation,
1066
+ )
1067
+ )
1068
+ elif tname == "Index":
1069
+ idx_name = _ast_const_str(tan.args[0]) if tan.args else f"ix_{table_name}_{tan.lineno}"
1070
+ idx_cols = [s for a in tan.args[1:] if (s := _ast_const_str(a))]
1071
+ idx_cid = build_db_canonical_id(
1072
+ DatabaseEntityKind.INDEX,
1073
+ table=table_name,
1074
+ name=idx_name or f"ix_{table_name}",
1075
+ dialect=dialect,
1076
+ schema=tbl_schema,
1077
+ )
1078
+ res.entities.append(
1079
+ DatabaseEntity(
1080
+ canonical_id=idx_cid,
1081
+ kind=DatabaseEntityKind.INDEX.value,
1082
+ name=idx_name or f"ix_{table_name}",
1083
+ dialect=dialect,
1084
+ schema_name=tbl_schema,
1085
+ table_name=table_name,
1086
+ framework=framework,
1087
+ file_path=file_path,
1088
+ start_line=tan.lineno,
1089
+ end_line=getattr(tan, "end_lineno", tan.lineno),
1090
+ evidence=line_snippet(tan.lineno),
1091
+ metadata={"columns": idx_cols},
1092
+ )
1093
+ )
1094
+ res.edges.append(
1095
+ RelationshipRecord(
1096
+ source=table_cid,
1097
+ target=idx_cid,
1098
+ relationship="HAS_INDEX",
1099
+ evidence_class="AST_VERIFIED",
1100
+ confidence="HIGH",
1101
+ file=file_path,
1102
+ start_line=tan.lineno,
1103
+ end_line=getattr(tan, "end_lineno", tan.lineno),
1104
+ evidence=line_snippet(tan.lineno),
1105
+ status="FACT",
1106
+ parser_version=PARSER_VERSION,
1107
+ index_generation=index_generation,
1108
+ )
1109
+ )
1110
+
1111
+ for uq_tuple in django_unique_together:
1112
+ uq_name = f"uq_{table_name}_{'_'.join(uq_tuple)}"
1113
+ uq_cid = build_db_canonical_id(
1114
+ DatabaseEntityKind.UNIQUE_CONSTRAINT,
1115
+ table=table_name,
1116
+ name=uq_name,
1117
+ dialect=dialect,
1118
+ schema=tbl_schema,
1119
+ )
1120
+ res.entities.append(
1121
+ DatabaseEntity(
1122
+ canonical_id=uq_cid,
1123
+ kind=DatabaseEntityKind.UNIQUE_CONSTRAINT.value,
1124
+ name=uq_name,
1125
+ dialect=dialect,
1126
+ schema_name=tbl_schema,
1127
+ table_name=table_name,
1128
+ framework=framework,
1129
+ file_path=file_path,
1130
+ start_line=cls_node.lineno,
1131
+ end_line=cls_node.lineno,
1132
+ evidence=f"unique_together = {uq_tuple!r}",
1133
+ metadata={"columns": list(uq_tuple)},
1134
+ )
1135
+ )
1136
+ res.edges.append(
1137
+ RelationshipRecord(
1138
+ source=table_cid,
1139
+ target=uq_cid,
1140
+ relationship="HAS_UNIQUE_CONSTRAINT",
1141
+ evidence_class="FRAMEWORK_VERIFIED",
1142
+ confidence="HIGH",
1143
+ file=file_path,
1144
+ start_line=cls_node.lineno,
1145
+ end_line=cls_node.lineno,
1146
+ evidence=f"unique_together = {uq_tuple!r}",
1147
+ status="FACT",
1148
+ parser_version=PARSER_VERSION,
1149
+ index_generation=index_generation,
1150
+ )
1151
+ )
1152
+
1153
+ # Inspect class attributes for columns, foreign keys, and relationships
1154
+ for stmt in cls_node.body:
1155
+ field_name: str | None = None
1156
+ call_node: ast.Call | None = None
1157
+ ann_type: str = ""
1158
+
1159
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
1160
+ field_name = stmt.targets[0].id
1161
+ if isinstance(stmt.value, ast.Call):
1162
+ call_node = stmt.value
1163
+ elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
1164
+ field_name = stmt.target.id
1165
+ ann_type = ast.unparse(stmt.annotation) if hasattr(ast, "unparse") else ""
1166
+ if isinstance(stmt.value, ast.Call):
1167
+ call_node = stmt.value
1168
+
1169
+ if not field_name or field_name.startswith("__"):
1170
+ continue
1171
+
1172
+ if call_node is not None:
1173
+ cname = _ast_call_name(call_node)
1174
+ short_cname = cname.split(".")[-1]
1175
+
1176
+ # 1. SQLAlchemy / Flask-SQLAlchemy / SQLModel Column / mapped_column / Field
1177
+ if short_cname in ("Column", "mapped_column", "Field"):
1178
+ _record_sqlalchemy_column(
1179
+ field_name,
1180
+ call_node,
1181
+ table_name,
1182
+ table_cid,
1183
+ model_canonical_id,
1184
+ framework,
1185
+ dialect,
1186
+ tbl_schema,
1187
+ file_path,
1188
+ line_snippet(stmt.lineno),
1189
+ index_generation,
1190
+ res,
1191
+ ann_type=ann_type,
1192
+ )
1193
+ # 2. SQLAlchemy / Flask-SQLAlchemy / SQLModel relationship / Relationship
1194
+ elif short_cname in ("relationship", "Relationship"):
1195
+ target_model = _ast_const_str(call_node.args[0]) if call_node.args else None
1196
+ if not target_model and ann_type:
1197
+ m_ann = re.search(r"\b([A-Z][A-Za-z0-9_]*)\b", ann_type)
1198
+ if m_ann and m_ann.group(1) not in ("Mapped", "List", "Optional", "Set"):
1199
+ target_model = m_ann.group(1)
1200
+ secondary_node = _get_kwarg(call_node, "secondary")
1201
+ secondary_tbl = _ast_const_str(secondary_node)
1202
+ if secondary_tbl is None and isinstance(secondary_node, ast.Name):
1203
+ secondary_tbl = secondary_node.id
1204
+ uselist_val = _ast_const_bool(_get_kwarg(call_node, "uselist"))
1205
+ rel_kind = "MANY_TO_MANY" if secondary_tbl else ("ONE_TO_ONE" if uselist_val is False else "ONE_TO_MANY")
1206
+ if target_model:
1207
+ tgt_tbl = model_to_table.get(target_model) or _camel_to_snake(target_model)
1208
+ tgt_tbl_cid = build_db_canonical_id(
1209
+ DatabaseEntityKind.TABLE,
1210
+ table=tgt_tbl,
1211
+ dialect=dialect,
1212
+ schema=tbl_schema,
1213
+ )
1214
+ res.edges.append(
1215
+ RelationshipRecord(
1216
+ source=f"{model_canonical_id}.{field_name}",
1217
+ target=tgt_tbl_cid,
1218
+ relationship="ORM_RELATION",
1219
+ evidence_class=ev_cls,
1220
+ confidence="HIGH",
1221
+ file=file_path,
1222
+ start_line=stmt.lineno,
1223
+ end_line=getattr(stmt, "end_lineno", stmt.lineno),
1224
+ evidence=line_snippet(stmt.lineno),
1225
+ reason=rel_kind,
1226
+ status="FACT",
1227
+ parser_version=PARSER_VERSION,
1228
+ index_generation=index_generation,
1229
+ metadata={"relation_type": rel_kind, "secondary": secondary_tbl},
1230
+ )
1231
+ )
1232
+ # 3. Django ORM fields
1233
+ elif short_cname in _DJANGO_FIELD_TYPES:
1234
+ _record_django_field(
1235
+ field_name,
1236
+ short_cname,
1237
+ call_node,
1238
+ table_name,
1239
+ table_cid,
1240
+ model_canonical_id,
1241
+ dialect,
1242
+ tbl_schema,
1243
+ model_to_table,
1244
+ file_path,
1245
+ line_snippet(stmt.lineno),
1246
+ index_generation,
1247
+ res,
1248
+ )
1249
+
1250
+
1251
+ def _record_sqlalchemy_column(
1252
+ field_name: str,
1253
+ call_node: ast.Call,
1254
+ table_name: str,
1255
+ table_cid: str,
1256
+ model_canonical_id: str | None,
1257
+ framework: str,
1258
+ dialect: str,
1259
+ schema: str,
1260
+ file_path: str,
1261
+ evidence: str,
1262
+ index_generation: int,
1263
+ res: DatabaseExtractionResult,
1264
+ ann_type: str = "",
1265
+ ) -> None:
1266
+ col_name = field_name.lower()
1267
+ data_type = ann_type or "UNKNOWN"
1268
+ is_pk = _ast_const_bool(_get_kwarg(call_node, "primary_key")) is True
1269
+ is_unique = _ast_const_bool(_get_kwarg(call_node, "unique")) is True
1270
+ is_index = _ast_const_bool(_get_kwarg(call_node, "index")) is True
1271
+ nullable_kw = _ast_const_bool(_get_kwarg(call_node, "nullable"))
1272
+ nullable = False if is_pk else (True if nullable_kw is None else nullable_kw)
1273
+
1274
+ default_node = _get_kwarg(call_node, "default") or _get_kwarg(call_node, "server_default")
1275
+ default_val: str | None = None
1276
+ if default_node is not None and isinstance(default_node, ast.Constant):
1277
+ default_val = redact_secrets( repr(default_node.value) )
1278
+
1279
+ fk_target: str | None = _ast_const_str(_get_kwarg(call_node, "foreign_key"))
1280
+
1281
+ for arg in call_node.args:
1282
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
1283
+ # Explicit column name or ForeignKey string
1284
+ if "." in arg.value:
1285
+ fk_target = arg.value
1286
+ else:
1287
+ col_name = arg.value.lower()
1288
+ elif isinstance(arg, ast.Name):
1289
+ data_type = arg.id
1290
+ elif isinstance(arg, ast.Attribute):
1291
+ data_type = arg.attr
1292
+ elif isinstance(arg, ast.Call):
1293
+ acname = _ast_call_name(arg).split(".")[-1]
1294
+ if acname == "ForeignKey" and arg.args:
1295
+ fk_target = _ast_const_str(arg.args[0])
1296
+ else:
1297
+ if arg.args and isinstance(arg.args[0], (ast.Constant, ast.UnaryOp)):
1298
+ arg_str = _ast_const_str(arg.args[0]) or (str(arg.args[0].value) if isinstance(arg.args[0], ast.Constant) else "")
1299
+ data_type = f"{acname}({arg_str})" if arg_str else acname
1300
+ else:
1301
+ data_type = acname
1302
+
1303
+ col_cid = build_db_canonical_id(
1304
+ DatabaseEntityKind.COLUMN,
1305
+ table=table_name,
1306
+ column=col_name,
1307
+ dialect=dialect,
1308
+ schema=schema,
1309
+ )
1310
+ tgt_tbl: str | None = None
1311
+ tgt_col: str | None = None
1312
+ if fk_target and "." in fk_target:
1313
+ parts = fk_target.split(".")
1314
+ tgt_tbl = parts[-2].lower()
1315
+ tgt_col = parts[-1].lower()
1316
+
1317
+ ev_cls = "FRAMEWORK_VERIFIED" if framework == "flask_sqlalchemy" else "AST_VERIFIED"
1318
+
1319
+ res.entities.append(
1320
+ DatabaseEntity(
1321
+ canonical_id=col_cid,
1322
+ kind=DatabaseEntityKind.COLUMN.value,
1323
+ name=col_name,
1324
+ dialect=dialect,
1325
+ schema_name=schema,
1326
+ table_name=table_name,
1327
+ column_name=col_name,
1328
+ data_type=data_type,
1329
+ nullable=nullable,
1330
+ default_value=default_val,
1331
+ is_primary_key=is_pk,
1332
+ is_foreign_key=bool(fk_target),
1333
+ is_unique=is_unique,
1334
+ is_indexed=is_index,
1335
+ target_table=tgt_tbl,
1336
+ target_column=tgt_col,
1337
+ orm_model_id=model_canonical_id,
1338
+ framework=framework,
1339
+ file_path=file_path,
1340
+ start_line=call_node.lineno,
1341
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1342
+ evidence=evidence,
1343
+ confidence="HIGH",
1344
+ evidence_class=ev_cls,
1345
+ )
1346
+ )
1347
+
1348
+ if model_canonical_id:
1349
+ res.edges.append(
1350
+ RelationshipRecord(
1351
+ source=f"{model_canonical_id}.{field_name}",
1352
+ target=col_cid,
1353
+ relationship="MAPS_TO_COLUMN",
1354
+ evidence_class=ev_cls,
1355
+ confidence="HIGH",
1356
+ file=file_path,
1357
+ start_line=call_node.lineno,
1358
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1359
+ evidence=evidence,
1360
+ status="FACT",
1361
+ parser_version=PARSER_VERSION,
1362
+ index_generation=index_generation,
1363
+ )
1364
+ )
1365
+
1366
+ if is_pk:
1367
+ pk_cid = build_db_canonical_id(
1368
+ DatabaseEntityKind.PRIMARY_KEY,
1369
+ table=table_name,
1370
+ column=col_name,
1371
+ dialect=dialect,
1372
+ schema=schema,
1373
+ )
1374
+ res.entities.append(
1375
+ DatabaseEntity(
1376
+ canonical_id=pk_cid,
1377
+ kind=DatabaseEntityKind.PRIMARY_KEY.value,
1378
+ name=f"pk_{table_name}_{col_name}",
1379
+ dialect=dialect,
1380
+ schema_name=schema,
1381
+ table_name=table_name,
1382
+ column_name=col_name,
1383
+ is_primary_key=True,
1384
+ framework=framework,
1385
+ file_path=file_path,
1386
+ start_line=call_node.lineno,
1387
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1388
+ evidence=evidence,
1389
+ )
1390
+ )
1391
+ res.edges.append(
1392
+ RelationshipRecord(
1393
+ source=table_cid,
1394
+ target=col_cid,
1395
+ relationship="HAS_PRIMARY_KEY",
1396
+ evidence_class=ev_cls,
1397
+ confidence="HIGH",
1398
+ file=file_path,
1399
+ start_line=call_node.lineno,
1400
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1401
+ evidence=evidence,
1402
+ status="FACT",
1403
+ parser_version=PARSER_VERSION,
1404
+ index_generation=index_generation,
1405
+ )
1406
+ )
1407
+
1408
+ if is_index:
1409
+ idx_cid = build_db_canonical_id(
1410
+ DatabaseEntityKind.INDEX,
1411
+ table=table_name,
1412
+ column=col_name,
1413
+ name=f"ix_{table_name}_{col_name}",
1414
+ dialect=dialect,
1415
+ schema=schema,
1416
+ )
1417
+ res.entities.append(
1418
+ DatabaseEntity(
1419
+ canonical_id=idx_cid,
1420
+ kind=DatabaseEntityKind.INDEX.value,
1421
+ name=f"ix_{table_name}_{col_name}",
1422
+ dialect=dialect,
1423
+ schema_name=schema,
1424
+ table_name=table_name,
1425
+ column_name=col_name,
1426
+ is_indexed=True,
1427
+ framework=framework,
1428
+ file_path=file_path,
1429
+ start_line=call_node.lineno,
1430
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1431
+ evidence=evidence,
1432
+ )
1433
+ )
1434
+ res.edges.append(
1435
+ RelationshipRecord(
1436
+ source=table_cid,
1437
+ target=idx_cid,
1438
+ relationship="HAS_INDEX",
1439
+ evidence_class=ev_cls,
1440
+ confidence="HIGH",
1441
+ file=file_path,
1442
+ start_line=call_node.lineno,
1443
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1444
+ evidence=evidence,
1445
+ status="FACT",
1446
+ parser_version=PARSER_VERSION,
1447
+ index_generation=index_generation,
1448
+ )
1449
+ )
1450
+
1451
+ if is_unique:
1452
+ uq_cid = build_db_canonical_id(
1453
+ DatabaseEntityKind.UNIQUE_CONSTRAINT,
1454
+ table=table_name,
1455
+ column=col_name,
1456
+ name=f"uq_{table_name}_{col_name}",
1457
+ dialect=dialect,
1458
+ schema=schema,
1459
+ )
1460
+ res.entities.append(
1461
+ DatabaseEntity(
1462
+ canonical_id=uq_cid,
1463
+ kind=DatabaseEntityKind.UNIQUE_CONSTRAINT.value,
1464
+ name=f"uq_{table_name}_{col_name}",
1465
+ dialect=dialect,
1466
+ schema_name=schema,
1467
+ table_name=table_name,
1468
+ column_name=col_name,
1469
+ is_unique=True,
1470
+ framework=framework,
1471
+ file_path=file_path,
1472
+ start_line=call_node.lineno,
1473
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1474
+ evidence=evidence,
1475
+ )
1476
+ )
1477
+ res.edges.append(
1478
+ RelationshipRecord(
1479
+ source=table_cid,
1480
+ target=uq_cid,
1481
+ relationship="HAS_UNIQUE_CONSTRAINT",
1482
+ evidence_class=ev_cls,
1483
+ confidence="HIGH",
1484
+ file=file_path,
1485
+ start_line=call_node.lineno,
1486
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1487
+ evidence=evidence,
1488
+ status="FACT",
1489
+ parser_version=PARSER_VERSION,
1490
+ index_generation=index_generation,
1491
+ )
1492
+ )
1493
+
1494
+ if tgt_tbl and tgt_col:
1495
+ target_col_cid = build_db_canonical_id(
1496
+ DatabaseEntityKind.COLUMN,
1497
+ table=tgt_tbl,
1498
+ column=tgt_col,
1499
+ dialect=dialect,
1500
+ schema=schema,
1501
+ )
1502
+ fk_cid = build_db_canonical_id(
1503
+ DatabaseEntityKind.FOREIGN_KEY,
1504
+ table=table_name,
1505
+ column=col_name,
1506
+ dialect=dialect,
1507
+ schema=schema,
1508
+ )
1509
+ res.entities.append(
1510
+ DatabaseEntity(
1511
+ canonical_id=fk_cid,
1512
+ kind=DatabaseEntityKind.FOREIGN_KEY.value,
1513
+ name=f"fk_{table_name}_{col_name}_{tgt_tbl}_{tgt_col}",
1514
+ dialect=dialect,
1515
+ schema_name=schema,
1516
+ table_name=table_name,
1517
+ column_name=col_name,
1518
+ is_foreign_key=True,
1519
+ target_table=tgt_tbl,
1520
+ target_column=tgt_col,
1521
+ framework=framework,
1522
+ file_path=file_path,
1523
+ start_line=call_node.lineno,
1524
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1525
+ evidence=evidence,
1526
+ )
1527
+ )
1528
+ res.edges.append(
1529
+ RelationshipRecord(
1530
+ source=col_cid,
1531
+ target=target_col_cid,
1532
+ relationship="FOREIGN_KEY_TO",
1533
+ evidence_class=ev_cls,
1534
+ confidence="HIGH",
1535
+ file=file_path,
1536
+ start_line=call_node.lineno,
1537
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1538
+ evidence=evidence,
1539
+ status="FACT",
1540
+ parser_version=PARSER_VERSION,
1541
+ index_generation=index_generation,
1542
+ )
1543
+ )
1544
+
1545
+
1546
+ def _record_django_field(
1547
+ field_name: str,
1548
+ field_type: str,
1549
+ call_node: ast.Call,
1550
+ table_name: str,
1551
+ table_cid: str,
1552
+ model_canonical_id: str,
1553
+ dialect: str,
1554
+ schema: str,
1555
+ model_to_table: dict[str, str],
1556
+ file_path: str,
1557
+ evidence: str,
1558
+ index_generation: int,
1559
+ res: DatabaseExtractionResult,
1560
+ ) -> None:
1561
+ is_pk = _ast_const_bool(_get_kwarg(call_node, "primary_key")) is True or field_type in ("AutoField", "BigAutoField")
1562
+ is_unique = _ast_const_bool(_get_kwarg(call_node, "unique")) is True or field_type == "OneToOneField"
1563
+ is_index = _ast_const_bool(_get_kwarg(call_node, "db_index")) is True
1564
+ nullable = _ast_const_bool(_get_kwarg(call_node, "null")) is True
1565
+
1566
+ if field_type in ("ForeignKey", "OneToOneField", "ManyToManyField"):
1567
+ target_model: str | None = None
1568
+ if call_node.args:
1569
+ arg0 = call_node.args[0]
1570
+ if isinstance(arg0, ast.Constant) and isinstance(arg0.value, str):
1571
+ target_model = arg0.value.split(".")[-1]
1572
+ elif isinstance(arg0, ast.Name):
1573
+ target_model = arg0.id
1574
+ if not target_model:
1575
+ to_kw = _get_kwarg(call_node, "to")
1576
+ if isinstance(to_kw, ast.Constant) and isinstance(to_kw.value, str):
1577
+ target_model = to_kw.value.split(".")[-1]
1578
+ elif isinstance(to_kw, ast.Name):
1579
+ target_model = to_kw.id
1580
+
1581
+ tgt_tbl = (model_to_table.get(target_model or "") or _pluralize_table(_camel_to_snake(target_model or "unknown")))
1582
+ tgt_tbl_cid = build_db_canonical_id(
1583
+ DatabaseEntityKind.TABLE,
1584
+ table=tgt_tbl,
1585
+ dialect=dialect,
1586
+ schema=schema,
1587
+ )
1588
+
1589
+ if field_type == "ManyToManyField":
1590
+ res.edges.append(
1591
+ RelationshipRecord(
1592
+ source=f"{model_canonical_id}.{field_name}",
1593
+ target=tgt_tbl_cid,
1594
+ relationship="ORM_RELATION",
1595
+ evidence_class="FRAMEWORK_VERIFIED",
1596
+ confidence="HIGH",
1597
+ file=file_path,
1598
+ start_line=call_node.lineno,
1599
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1600
+ evidence=evidence,
1601
+ reason="MANY_TO_MANY",
1602
+ status="FACT",
1603
+ parser_version=PARSER_VERSION,
1604
+ index_generation=index_generation,
1605
+ )
1606
+ )
1607
+ return
1608
+
1609
+ col_name = f"{field_name.lower()}_id" if not field_name.lower().endswith("_id") else field_name.lower()
1610
+ col_cid = build_db_canonical_id(
1611
+ DatabaseEntityKind.COLUMN,
1612
+ table=table_name,
1613
+ column=col_name,
1614
+ dialect=dialect,
1615
+ schema=schema,
1616
+ )
1617
+ tgt_col_cid = build_db_canonical_id(
1618
+ DatabaseEntityKind.COLUMN,
1619
+ table=tgt_tbl,
1620
+ column="id",
1621
+ dialect=dialect,
1622
+ schema=schema,
1623
+ )
1624
+ res.entities.append(
1625
+ DatabaseEntity(
1626
+ canonical_id=col_cid,
1627
+ kind=DatabaseEntityKind.COLUMN.value,
1628
+ name=col_name,
1629
+ dialect=dialect,
1630
+ schema_name=schema,
1631
+ table_name=table_name,
1632
+ column_name=col_name,
1633
+ data_type=field_type,
1634
+ nullable=nullable,
1635
+ is_foreign_key=True,
1636
+ is_unique=is_unique,
1637
+ target_table=tgt_tbl,
1638
+ target_column="id",
1639
+ orm_model_id=model_canonical_id,
1640
+ framework="django_orm",
1641
+ file_path=file_path,
1642
+ start_line=call_node.lineno,
1643
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1644
+ evidence=evidence,
1645
+ evidence_class="FRAMEWORK_VERIFIED",
1646
+ )
1647
+ )
1648
+ res.edges.append(
1649
+ RelationshipRecord(
1650
+ source=f"{model_canonical_id}.{field_name}",
1651
+ target=col_cid,
1652
+ relationship="MAPS_TO_COLUMN",
1653
+ evidence_class="FRAMEWORK_VERIFIED",
1654
+ confidence="HIGH",
1655
+ file=file_path,
1656
+ start_line=call_node.lineno,
1657
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1658
+ evidence=evidence,
1659
+ status="FACT",
1660
+ parser_version=PARSER_VERSION,
1661
+ index_generation=index_generation,
1662
+ )
1663
+ )
1664
+ res.edges.append(
1665
+ RelationshipRecord(
1666
+ source=col_cid,
1667
+ target=tgt_col_cid,
1668
+ relationship="FOREIGN_KEY_TO",
1669
+ evidence_class="FRAMEWORK_VERIFIED",
1670
+ confidence="HIGH",
1671
+ file=file_path,
1672
+ start_line=call_node.lineno,
1673
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1674
+ evidence=evidence,
1675
+ status="FACT",
1676
+ parser_version=PARSER_VERSION,
1677
+ index_generation=index_generation,
1678
+ )
1679
+ )
1680
+ return
1681
+
1682
+ col_name = field_name.lower()
1683
+ col_cid = build_db_canonical_id(
1684
+ DatabaseEntityKind.COLUMN,
1685
+ table=table_name,
1686
+ column=col_name,
1687
+ dialect=dialect,
1688
+ schema=schema,
1689
+ )
1690
+ max_len_node = _get_kwarg(call_node, "max_length")
1691
+ effective_data_type = field_type
1692
+ if max_len_node is not None:
1693
+ if isinstance(max_len_node, ast.Constant) and isinstance(max_len_node.value, int):
1694
+ effective_data_type = f"{field_type}({max_len_node.value})"
1695
+
1696
+ res.entities.append(
1697
+ DatabaseEntity(
1698
+ canonical_id=col_cid,
1699
+ kind=DatabaseEntityKind.COLUMN.value,
1700
+ name=col_name,
1701
+ dialect=dialect,
1702
+ schema_name=schema,
1703
+ table_name=table_name,
1704
+ column_name=col_name,
1705
+ data_type=effective_data_type,
1706
+ nullable=nullable,
1707
+ is_primary_key=is_pk,
1708
+ is_unique=is_unique,
1709
+ is_indexed=is_index,
1710
+ orm_model_id=model_canonical_id,
1711
+ framework="django_orm",
1712
+ file_path=file_path,
1713
+ start_line=call_node.lineno,
1714
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1715
+ evidence=evidence,
1716
+ evidence_class="FRAMEWORK_VERIFIED",
1717
+ )
1718
+ )
1719
+ res.edges.append(
1720
+ RelationshipRecord(
1721
+ source=f"{model_canonical_id}.{field_name}",
1722
+ target=col_cid,
1723
+ relationship="MAPS_TO_COLUMN",
1724
+ evidence_class="FRAMEWORK_VERIFIED",
1725
+ confidence="HIGH",
1726
+ file=file_path,
1727
+ start_line=call_node.lineno,
1728
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1729
+ evidence=evidence,
1730
+ status="FACT",
1731
+ parser_version=PARSER_VERSION,
1732
+ index_generation=index_generation,
1733
+ )
1734
+ )
1735
+ if is_pk:
1736
+ res.edges.append(
1737
+ RelationshipRecord(
1738
+ source=table_cid,
1739
+ target=col_cid,
1740
+ relationship="HAS_PRIMARY_KEY",
1741
+ evidence_class="FRAMEWORK_VERIFIED",
1742
+ confidence="HIGH",
1743
+ file=file_path,
1744
+ start_line=call_node.lineno,
1745
+ end_line=getattr(call_node, "end_lineno", call_node.lineno),
1746
+ evidence=evidence,
1747
+ status="FACT",
1748
+ parser_version=PARSER_VERSION,
1749
+ index_generation=index_generation,
1750
+ )
1751
+ )
1752
+
1753
+
1754
+ def _extract_python_queries_in_scope(
1755
+ tree: ast.AST,
1756
+ file_path: str,
1757
+ module_name: str,
1758
+ dialect: str,
1759
+ schema: str,
1760
+ model_to_table: dict[str, str],
1761
+ line_snippet: Any,
1762
+ index_generation: int,
1763
+ res: DatabaseExtractionResult,
1764
+ ) -> None:
1765
+ """Walk functions/methods and extract ORM queries, session mutations, and raw SQL operations."""
1766
+
1767
+ def _visit_scope(
1768
+ node: ast.AST,
1769
+ scope_stack: list[str],
1770
+ local_model_vars: dict[str, str],
1771
+ local_sql_vars: dict[str, str],
1772
+ ) -> None:
1773
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1774
+ next_scope = [*scope_stack, node.name]
1775
+ next_model_vars = dict(local_model_vars)
1776
+ next_sql_vars = dict(local_sql_vars)
1777
+ for child in ast.iter_child_nodes(node):
1778
+ _visit_scope(child, next_scope, next_model_vars, next_sql_vars)
1779
+ return
1780
+
1781
+ # Track local variable bindings: e.g. `order = Order(user_id=1)` or `sql = "SELECT * FROM users"`
1782
+ if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
1783
+ var_name = node.targets[0].id
1784
+ if isinstance(node.value, ast.Call):
1785
+ cname = _ast_call_name(node.value)
1786
+ if cname in model_to_table:
1787
+ local_model_vars[var_name] = model_to_table[cname]
1788
+ elif cname and cname[0].isupper() and cname.split(".")[-1] in model_to_table:
1789
+ local_model_vars[var_name] = model_to_table[cname.split(".")[-1]]
1790
+ elif isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
1791
+ local_sql_vars[var_name] = node.value.value
1792
+
1793
+ if isinstance(node, ast.Call):
1794
+ caller_id = (
1795
+ f"{module_name}.{'.'.join(scope_stack)}"
1796
+ if scope_stack
1797
+ else module_name
1798
+ )
1799
+ _inspect_call_for_db_query(
1800
+ node,
1801
+ caller_id,
1802
+ file_path,
1803
+ dialect,
1804
+ schema,
1805
+ model_to_table,
1806
+ local_model_vars,
1807
+ local_sql_vars,
1808
+ line_snippet,
1809
+ index_generation,
1810
+ res,
1811
+ )
1812
+
1813
+ for child in ast.iter_child_nodes(node):
1814
+ _visit_scope(child, scope_stack, local_model_vars, local_sql_vars)
1815
+
1816
+ _visit_scope(tree, [], {}, {})
1817
+
1818
+
1819
+ def _inspect_call_for_db_query(
1820
+ call: ast.Call,
1821
+ caller_id: str,
1822
+ file_path: str,
1823
+ dialect: str,
1824
+ schema: str,
1825
+ model_to_table: dict[str, str],
1826
+ local_model_vars: dict[str, str],
1827
+ local_sql_vars: dict[str, str],
1828
+ line_snippet: Any,
1829
+ index_generation: int,
1830
+ res: DatabaseExtractionResult,
1831
+ ) -> None:
1832
+ cname = _ast_call_name(call)
1833
+ short_name = cname.split(".")[-1]
1834
+ ev_line = line_snippet(call.lineno)
1835
+
1836
+ # 1. Raw SQL execution: cursor.execute(...), con.execute(...), conn.fetch(...), session.execute(text(...))
1837
+ if short_name in ("execute", "executemany", "fetch", "fetchrow", "fetchval", "raw") and call.args:
1838
+ arg0 = call.args[0]
1839
+ raw_sql: str | None = _ast_const_str(arg0)
1840
+ if raw_sql is None and isinstance(arg0, ast.Name) and arg0.id in local_sql_vars:
1841
+ raw_sql = local_sql_vars[arg0.id]
1842
+ elif raw_sql is None and isinstance(arg0, ast.Call) and _ast_call_name(arg0).endswith("text") and arg0.args:
1843
+ raw_sql = _ast_const_str(arg0.args[0])
1844
+
1845
+ if raw_sql:
1846
+ parsed_ops = parse_sql_statement(raw_sql)
1847
+ for op_info in parsed_ops:
1848
+ op = str(op_info["operation"])
1849
+ rel = str(op_info["relationship"])
1850
+ cols = tuple(str(c) for c in op_info.get("columns", []))
1851
+ for tbl in op_info.get("tables", []):
1852
+ _emit_query_fact_and_edges(
1853
+ caller_id=caller_id,
1854
+ operation=op,
1855
+ relationship=rel,
1856
+ table_name=tbl,
1857
+ columns=cols,
1858
+ read_columns=tuple(str(c) for c in op_info.get("read_columns", [])),
1859
+ framework="raw_sql",
1860
+ normalized_sql=str(op_info.get("normalized_sql") or ""),
1861
+ dialect=dialect,
1862
+ schema=schema,
1863
+ file_path=file_path,
1864
+ start_line=call.lineno,
1865
+ end_line=getattr(call, "end_lineno", call.lineno),
1866
+ evidence=ev_line,
1867
+ confidence="HIGH",
1868
+ evidence_class="AST_VERIFIED",
1869
+ status="FACT",
1870
+ index_generation=index_generation,
1871
+ res=res,
1872
+ )
1873
+ return
1874
+
1875
+ # Dynamic f-string or binary-op SQL where table name is not statically provable
1876
+ if isinstance(arg0, (ast.JoinedStr, ast.BinOp)):
1877
+ static_parts = ""
1878
+ if isinstance(arg0, ast.JoinedStr):
1879
+ static_parts = " ".join(
1880
+ v.value for v in arg0.values if isinstance(v, ast.Constant) and isinstance(v.value, str)
1881
+ )
1882
+ if any(k in static_parts.upper() for k in ("SELECT ", "INSERT ", "UPDATE ", "DELETE ")):
1883
+ unk_cid = build_db_canonical_id(
1884
+ DatabaseEntityKind.TABLE,
1885
+ table="UNKNOWN",
1886
+ dialect=dialect,
1887
+ schema=schema,
1888
+ )
1889
+ q_id = f"q:{file_path}:{call.lineno}:{caller_id}:UNKNOWN"
1890
+ res.queries.append(
1891
+ DatabaseQueryFact(
1892
+ query_id=q_id,
1893
+ caller_symbol_id=caller_id,
1894
+ operation="UNKNOWN",
1895
+ relationship="UNKNOWN_TABLE",
1896
+ table_name="UNKNOWN",
1897
+ table_canonical_id=unk_cid,
1898
+ framework="raw_sql",
1899
+ normalized_sql=normalize_and_redact_sql(static_parts),
1900
+ file_path=file_path,
1901
+ start_line=call.lineno,
1902
+ end_line=getattr(call, "end_lineno", call.lineno),
1903
+ evidence=ev_line,
1904
+ confidence="UNKNOWN",
1905
+ evidence_class="UNKNOWN",
1906
+ status="UNKNOWN",
1907
+ )
1908
+ )
1909
+ res.edges.append(
1910
+ RelationshipRecord(
1911
+ source=caller_id,
1912
+ target=unk_cid,
1913
+ relationship="UNKNOWN_TABLE",
1914
+ evidence_class="UNKNOWN",
1915
+ confidence="UNKNOWN",
1916
+ file=file_path,
1917
+ start_line=call.lineno,
1918
+ end_line=getattr(call, "end_lineno", call.lineno),
1919
+ evidence=ev_line,
1920
+ reason="dynamic_sql_table_unresolvable",
1921
+ status="UNKNOWN",
1922
+ parser_version=PARSER_VERSION,
1923
+ index_generation=index_generation,
1924
+ )
1925
+ )
1926
+ return
1927
+
1928
+ # 2. SQLAlchemy 1.x / 2.x select(Model), insert(Model), update(Model), delete(Model), session.query(Model)
1929
+ if short_name in ("query", "select", "insert", "update", "delete") and call.args:
1930
+ arg0 = call.args[0]
1931
+ model_name: str | None = None
1932
+ if isinstance(arg0, ast.Name):
1933
+ model_name = arg0.id
1934
+ elif isinstance(arg0, ast.Attribute):
1935
+ model_name = arg0.attr
1936
+
1937
+ if model_name and model_name in model_to_table:
1938
+ tbl_name = model_to_table[model_name]
1939
+ if short_name in ("query", "select"):
1940
+ op, rel = "SELECT", "READS_TABLE"
1941
+ elif short_name == "insert":
1942
+ op, rel = "INSERT", "WRITES_TABLE"
1943
+ elif short_name == "update":
1944
+ op, rel = "UPDATE", "WRITES_TABLE"
1945
+ else:
1946
+ op, rel = "DELETE", "WRITES_TABLE"
1947
+ _emit_query_fact_and_edges(
1948
+ caller_id=caller_id,
1949
+ operation=op,
1950
+ relationship=rel,
1951
+ table_name=tbl_name,
1952
+ columns=(),
1953
+ read_columns=(),
1954
+ framework="sqlalchemy",
1955
+ normalized_sql=f"{op} {tbl_name}",
1956
+ dialect=dialect,
1957
+ schema=schema,
1958
+ file_path=file_path,
1959
+ start_line=call.lineno,
1960
+ end_line=getattr(call, "end_lineno", call.lineno),
1961
+ evidence=ev_line,
1962
+ confidence="HIGH",
1963
+ evidence_class="AST_VERIFIED",
1964
+ status="FACT",
1965
+ index_generation=index_generation,
1966
+ res=res,
1967
+ )
1968
+ return
1969
+
1970
+ # 3. session.add(obj), db.session.add(obj), session.delete(obj)
1971
+ if short_name in ("add", "add_all", "merge") and call.args:
1972
+ is_session_call = False
1973
+ if isinstance(call.func, ast.Attribute):
1974
+ recv = ast.unparse(call.func.value) if hasattr(ast, "unparse") else ""
1975
+ if any(s in recv.lower() for s in ("session", "db")):
1976
+ is_session_call = True
1977
+ if is_session_call:
1978
+ arg0 = call.args[0]
1979
+ add_tbl_name: str | None = None
1980
+ add_cols: list[str] = []
1981
+ if isinstance(arg0, ast.Call):
1982
+ mname = _ast_call_name(arg0).split(".")[-1]
1983
+ add_tbl_name = model_to_table.get(mname)
1984
+ add_cols = [kw.arg.lower() for kw in arg0.keywords if kw.arg]
1985
+ elif isinstance(arg0, ast.Name) and arg0.id in local_model_vars:
1986
+ add_tbl_name = local_model_vars[arg0.id]
1987
+ if add_tbl_name:
1988
+ _emit_query_fact_and_edges(
1989
+ caller_id=caller_id,
1990
+ operation="INSERT",
1991
+ relationship="WRITES_TABLE",
1992
+ table_name=add_tbl_name,
1993
+ columns=tuple(add_cols),
1994
+ read_columns=(),
1995
+ framework="sqlalchemy",
1996
+ normalized_sql=f"INSERT INTO {add_tbl_name}",
1997
+ dialect=dialect,
1998
+ schema=schema,
1999
+ file_path=file_path,
2000
+ start_line=call.lineno,
2001
+ end_line=getattr(call, "end_lineno", call.lineno),
2002
+ evidence=ev_line,
2003
+ confidence="HIGH",
2004
+ evidence_class="DATAFLOW_VERIFIED",
2005
+ status="FACT",
2006
+ index_generation=index_generation,
2007
+ res=res,
2008
+ )
2009
+ return
2010
+
2011
+ # 4. Django ORM `Model.objects.<method>(...)` or Flask-SQLAlchemy `Model.query.<method>(...)`
2012
+ if isinstance(call.func, ast.Attribute):
2013
+ method_name = call.func.attr
2014
+ # Unwrap chained calls like Model.objects.filter(...).update(...)
2015
+ curr: ast.AST = call.func.value
2016
+ root_model: str | None = None
2017
+ manager_kind: str = ""
2018
+ while isinstance(curr, ast.Call) and isinstance(curr.func, ast.Attribute):
2019
+ curr = curr.func.value
2020
+ if isinstance(curr, ast.Attribute) and isinstance(curr.value, ast.Name):
2021
+ if curr.attr in ("objects", "query"):
2022
+ root_model = curr.value.id
2023
+ manager_kind = curr.attr
2024
+
2025
+ if root_model and root_model in model_to_table:
2026
+ tbl_name = model_to_table[root_model]
2027
+ fw = "django_orm" if manager_kind == "objects" else "flask_sqlalchemy"
2028
+ kw_cols = tuple(
2029
+ kw.arg.split("__")[0].lower()
2030
+ for kw in call.keywords
2031
+ if kw.arg and kw.arg not in ("defaults",)
2032
+ )
2033
+ if method_name in ("filter", "filter_by", "get", "all", "first", "last", "count", "exists", "values", "values_list"):
2034
+ _emit_query_fact_and_edges(
2035
+ caller_id=caller_id,
2036
+ operation="SELECT",
2037
+ relationship="READS_TABLE",
2038
+ table_name=tbl_name,
2039
+ columns=kw_cols,
2040
+ read_columns=kw_cols,
2041
+ framework=fw,
2042
+ normalized_sql=f"SELECT FROM {tbl_name}",
2043
+ dialect=dialect,
2044
+ schema=schema,
2045
+ file_path=file_path,
2046
+ start_line=call.lineno,
2047
+ end_line=getattr(call, "end_lineno", call.lineno),
2048
+ evidence=ev_line,
2049
+ confidence="HIGH",
2050
+ evidence_class="FRAMEWORK_VERIFIED",
2051
+ status="FACT",
2052
+ index_generation=index_generation,
2053
+ res=res,
2054
+ )
2055
+ elif method_name in ("create", "bulk_create", "get_or_create", "update_or_create"):
2056
+ _emit_query_fact_and_edges(
2057
+ caller_id=caller_id,
2058
+ operation="INSERT",
2059
+ relationship="WRITES_TABLE",
2060
+ table_name=tbl_name,
2061
+ columns=kw_cols,
2062
+ read_columns=(),
2063
+ framework=fw,
2064
+ normalized_sql=f"INSERT INTO {tbl_name}",
2065
+ dialect=dialect,
2066
+ schema=schema,
2067
+ file_path=file_path,
2068
+ start_line=call.lineno,
2069
+ end_line=getattr(call, "end_lineno", call.lineno),
2070
+ evidence=ev_line,
2071
+ confidence="HIGH",
2072
+ evidence_class="FRAMEWORK_VERIFIED",
2073
+ status="FACT",
2074
+ index_generation=index_generation,
2075
+ res=res,
2076
+ )
2077
+ elif method_name in ("update", "bulk_update"):
2078
+ _emit_query_fact_and_edges(
2079
+ caller_id=caller_id,
2080
+ operation="UPDATE",
2081
+ relationship="WRITES_TABLE",
2082
+ table_name=tbl_name,
2083
+ columns=kw_cols,
2084
+ read_columns=(),
2085
+ framework=fw,
2086
+ normalized_sql=f"UPDATE {tbl_name}",
2087
+ dialect=dialect,
2088
+ schema=schema,
2089
+ file_path=file_path,
2090
+ start_line=call.lineno,
2091
+ end_line=getattr(call, "end_lineno", call.lineno),
2092
+ evidence=ev_line,
2093
+ confidence="HIGH",
2094
+ evidence_class="FRAMEWORK_VERIFIED",
2095
+ status="FACT",
2096
+ index_generation=index_generation,
2097
+ res=res,
2098
+ )
2099
+ elif method_name == "delete":
2100
+ _emit_query_fact_and_edges(
2101
+ caller_id=caller_id,
2102
+ operation="DELETE",
2103
+ relationship="WRITES_TABLE",
2104
+ table_name=tbl_name,
2105
+ columns=(),
2106
+ read_columns=(),
2107
+ framework=fw,
2108
+ normalized_sql=f"DELETE FROM {tbl_name}",
2109
+ dialect=dialect,
2110
+ schema=schema,
2111
+ file_path=file_path,
2112
+ start_line=call.lineno,
2113
+ end_line=getattr(call, "end_lineno", call.lineno),
2114
+ evidence=ev_line,
2115
+ confidence="HIGH",
2116
+ evidence_class="FRAMEWORK_VERIFIED",
2117
+ status="FACT",
2118
+ index_generation=index_generation,
2119
+ res=res,
2120
+ )
2121
+
2122
+
2123
+ def _emit_query_fact_and_edges(
2124
+ caller_id: str,
2125
+ operation: str,
2126
+ relationship: str,
2127
+ table_name: str,
2128
+ columns: tuple[str, ...],
2129
+ read_columns: tuple[str, ...],
2130
+ framework: str,
2131
+ normalized_sql: str,
2132
+ dialect: str,
2133
+ schema: str,
2134
+ file_path: str,
2135
+ start_line: int,
2136
+ end_line: int,
2137
+ evidence: str,
2138
+ confidence: str,
2139
+ evidence_class: str,
2140
+ status: str,
2141
+ index_generation: int,
2142
+ res: DatabaseExtractionResult,
2143
+ ) -> None:
2144
+ tbl_clean = table_name.strip().lower()
2145
+ tbl_cid = build_db_canonical_id(
2146
+ DatabaseEntityKind.TABLE,
2147
+ table=tbl_clean,
2148
+ dialect=dialect,
2149
+ schema=schema,
2150
+ )
2151
+ q_id = f"q:{file_path}:{start_line}:{caller_id}:{operation}:{tbl_clean}"
2152
+ res.queries.append(
2153
+ DatabaseQueryFact(
2154
+ query_id=q_id,
2155
+ caller_symbol_id=caller_id,
2156
+ operation=operation,
2157
+ relationship=relationship,
2158
+ table_name=tbl_clean,
2159
+ table_canonical_id=tbl_cid,
2160
+ columns=columns,
2161
+ framework=framework,
2162
+ normalized_sql=normalized_sql,
2163
+ file_path=file_path,
2164
+ start_line=start_line,
2165
+ end_line=end_line,
2166
+ evidence=evidence,
2167
+ confidence=confidence,
2168
+ evidence_class=evidence_class,
2169
+ status=status,
2170
+ )
2171
+ )
2172
+ res.edges.append(
2173
+ RelationshipRecord(
2174
+ source=caller_id,
2175
+ target=tbl_cid,
2176
+ relationship=relationship,
2177
+ evidence_class=evidence_class,
2178
+ confidence=confidence,
2179
+ file=file_path,
2180
+ start_line=start_line,
2181
+ end_line=end_line,
2182
+ evidence=evidence,
2183
+ status=status,
2184
+ parser_version=PARSER_VERSION,
2185
+ index_generation=index_generation,
2186
+ )
2187
+ )
2188
+
2189
+ col_rel = "WRITES_COLUMN" if relationship == "WRITES_TABLE" else "READS_COLUMN"
2190
+ for col in columns:
2191
+ col_cid = build_db_canonical_id(
2192
+ DatabaseEntityKind.COLUMN,
2193
+ table=tbl_clean,
2194
+ column=col,
2195
+ dialect=dialect,
2196
+ schema=schema,
2197
+ )
2198
+ res.edges.append(
2199
+ RelationshipRecord(
2200
+ source=caller_id,
2201
+ target=col_cid,
2202
+ relationship=col_rel,
2203
+ evidence_class=evidence_class,
2204
+ confidence=confidence,
2205
+ file=file_path,
2206
+ start_line=start_line,
2207
+ end_line=end_line,
2208
+ evidence=evidence,
2209
+ status=status,
2210
+ parser_version=PARSER_VERSION,
2211
+ index_generation=index_generation,
2212
+ )
2213
+ )
2214
+
2215
+ for rcol in read_columns:
2216
+ if rcol not in columns or col_rel != "READS_COLUMN":
2217
+ rcol_cid = build_db_canonical_id(
2218
+ DatabaseEntityKind.COLUMN,
2219
+ table=tbl_clean,
2220
+ column=rcol,
2221
+ dialect=dialect,
2222
+ schema=schema,
2223
+ )
2224
+ res.edges.append(
2225
+ RelationshipRecord(
2226
+ source=caller_id,
2227
+ target=rcol_cid,
2228
+ relationship="READS_COLUMN",
2229
+ evidence_class=evidence_class,
2230
+ confidence=confidence,
2231
+ file=file_path,
2232
+ start_line=start_line,
2233
+ end_line=end_line,
2234
+ evidence=evidence,
2235
+ status=status,
2236
+ parser_version=PARSER_VERSION,
2237
+ index_generation=index_generation,
2238
+ )
2239
+ )
2240
+
2241
+
2242
+ # ---------------------------------------------------------------------------
2243
+ # Migration Intelligence (Alembic, Django, SQL, Prisma)
2244
+ # ---------------------------------------------------------------------------
2245
+
2246
+
2247
+ def _extract_alembic_migration(
2248
+ tree: ast.Module,
2249
+ content: str,
2250
+ file_path: str,
2251
+ module_name: str,
2252
+ dialect: str,
2253
+ schema: str,
2254
+ index_generation: int,
2255
+ res: DatabaseExtractionResult,
2256
+ ) -> None:
2257
+ revision = module_name.split(".")[-1]
2258
+ down_revision: str | None = None
2259
+ created_tables: list[str] = []
2260
+ dropped_tables: list[str] = []
2261
+ added_columns: list[str] = []
2262
+ removed_columns: list[str] = []
2263
+ renamed_columns: list[str] = []
2264
+ indexes: list[str] = []
2265
+ foreign_keys: list[str] = []
2266
+ constraints: list[str] = []
2267
+ affected_tables: set[str] = set()
2268
+
2269
+ for node in tree.body:
2270
+ if isinstance(node, ast.Assign):
2271
+ for tgt in node.targets:
2272
+ if isinstance(tgt, ast.Name) and tgt.id == "revision":
2273
+ revision = _ast_const_str(node.value) or revision
2274
+ elif isinstance(tgt, ast.Name) and tgt.id == "down_revision":
2275
+ down_revision = _ast_const_str(node.value)
2276
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
2277
+ if node.target.id == "revision" and node.value:
2278
+ revision = _ast_const_str(node.value) or revision
2279
+ elif node.target.id == "down_revision" and node.value:
2280
+ down_revision = _ast_const_str(node.value)
2281
+
2282
+ # Walk upgrade() function (or module body if no upgrade function)
2283
+ upgrade_nodes: list[ast.AST] = [
2284
+ n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "upgrade"
2285
+ ] or [tree]
2286
+
2287
+ for up_node in upgrade_nodes:
2288
+ for child in ast.walk(up_node):
2289
+ if not isinstance(child, ast.Call):
2290
+ continue
2291
+ cname = _ast_call_name(child)
2292
+ if not cname.startswith("op."):
2293
+ continue
2294
+ op_name = cname.split(".")[-1]
2295
+ if op_name == "create_table" and child.args:
2296
+ tbl = _ast_const_str(child.args[0])
2297
+ if tbl:
2298
+ t_clean = tbl.lower()
2299
+ created_tables.append(t_clean)
2300
+ affected_tables.add(t_clean)
2301
+ tbl_cid = build_db_canonical_id(
2302
+ DatabaseEntityKind.TABLE,
2303
+ table=t_clean,
2304
+ dialect=dialect,
2305
+ schema=schema,
2306
+ )
2307
+ res.entities.append(
2308
+ DatabaseEntity(
2309
+ canonical_id=tbl_cid,
2310
+ kind=DatabaseEntityKind.TABLE.value,
2311
+ name=t_clean,
2312
+ dialect=dialect,
2313
+ schema_name=schema,
2314
+ table_name=t_clean,
2315
+ framework="alembic",
2316
+ file_path=file_path,
2317
+ start_line=child.lineno,
2318
+ end_line=getattr(child, "end_lineno", child.lineno),
2319
+ evidence=f"op.create_table({t_clean!r})",
2320
+ )
2321
+ )
2322
+ for sub in child.args[1:]:
2323
+ if isinstance(sub, ast.Call):
2324
+ sc = _ast_call_name(sub).split(".")[-1]
2325
+ if sc == "Column" and sub.args:
2326
+ col_nm = _ast_const_str(sub.args[0])
2327
+ if col_nm:
2328
+ added_columns.append(f"{t_clean}.{col_nm.lower()}")
2329
+ _record_sqlalchemy_column(
2330
+ col_nm,
2331
+ sub,
2332
+ t_clean,
2333
+ tbl_cid,
2334
+ None,
2335
+ "alembic",
2336
+ dialect,
2337
+ schema,
2338
+ file_path,
2339
+ f"op.create_table({t_clean!r}) -> Column({col_nm!r})",
2340
+ index_generation,
2341
+ res,
2342
+ )
2343
+ elif op_name == "drop_table" and child.args:
2344
+ tbl = _ast_const_str(child.args[0])
2345
+ if tbl:
2346
+ dropped_tables.append(tbl.lower())
2347
+ affected_tables.add(tbl.lower())
2348
+ elif op_name == "add_column" and len(child.args) >= 2:
2349
+ tbl = _ast_const_str(child.args[0])
2350
+ col_arg = child.args[1]
2351
+ if tbl and isinstance(col_arg, ast.Call) and col_arg.args:
2352
+ col_nm = _ast_const_str(col_arg.args[0])
2353
+ if col_nm:
2354
+ t_clean = tbl.lower()
2355
+ c_clean = col_nm.lower()
2356
+ added_columns.append(f"{t_clean}.{c_clean}")
2357
+ affected_tables.add(t_clean)
2358
+ tbl_cid = build_db_canonical_id(
2359
+ DatabaseEntityKind.TABLE,
2360
+ table=t_clean,
2361
+ dialect=dialect,
2362
+ schema=schema,
2363
+ )
2364
+ _record_sqlalchemy_column(
2365
+ c_clean,
2366
+ col_arg,
2367
+ t_clean,
2368
+ tbl_cid,
2369
+ None,
2370
+ "alembic",
2371
+ dialect,
2372
+ schema,
2373
+ file_path,
2374
+ f"op.add_column({t_clean!r}, Column({c_clean!r}))",
2375
+ index_generation,
2376
+ res,
2377
+ )
2378
+ elif op_name == "drop_column" and len(child.args) >= 2:
2379
+ tbl = _ast_const_str(child.args[0])
2380
+ col_nm = _ast_const_str(child.args[1])
2381
+ if tbl and col_nm:
2382
+ removed_columns.append(f"{tbl.lower()}.{col_nm.lower()}")
2383
+ affected_tables.add(tbl.lower())
2384
+ elif op_name == "alter_column" and len(child.args) >= 2:
2385
+ tbl = _ast_const_str(child.args[0])
2386
+ col_nm = _ast_const_str(child.args[1])
2387
+ new_nm = _ast_const_str(_get_kwarg(child, "new_column_name"))
2388
+ if tbl and col_nm and new_nm:
2389
+ renamed_columns.append(f"{tbl.lower()}.{col_nm.lower()}->{new_nm.lower()}")
2390
+ affected_tables.add(tbl.lower())
2391
+ elif op_name == "create_index" and len(child.args) >= 2:
2392
+ idx_nm = _ast_const_str(child.args[0])
2393
+ tbl = _ast_const_str(child.args[1])
2394
+ if idx_nm and tbl:
2395
+ indexes.append(f"{tbl.lower()}.{idx_nm}")
2396
+ affected_tables.add(tbl.lower())
2397
+ elif op_name == "create_foreign_key" and len(child.args) >= 3:
2398
+ fk_nm = _ast_const_str(child.args[0]) or "fk"
2399
+ src_tbl = _ast_const_str(child.args[1])
2400
+ tgt_tbl = _ast_const_str(child.args[2])
2401
+ if src_tbl and tgt_tbl:
2402
+ foreign_keys.append(f"{src_tbl.lower()}->{tgt_tbl.lower()}({fk_nm})")
2403
+ affected_tables.add(src_tbl.lower())
2404
+ affected_tables.add(tgt_tbl.lower())
2405
+ elif op_name in ("create_unique_constraint", "create_check_constraint") and len(child.args) >= 2:
2406
+ c_nm = _ast_const_str(child.args[0]) or op_name
2407
+ tbl = _ast_const_str(child.args[1])
2408
+ if tbl:
2409
+ constraints.append(f"{tbl.lower()}.{c_nm}")
2410
+ affected_tables.add(tbl.lower())
2411
+
2412
+ mig_cid = build_db_canonical_id(
2413
+ DatabaseEntityKind.MIGRATION,
2414
+ name=revision,
2415
+ module="alembic",
2416
+ )
2417
+ mig_fact = MigrationFact(
2418
+ migration_id=revision,
2419
+ canonical_id=mig_cid,
2420
+ system="alembic",
2421
+ revision=revision,
2422
+ down_revision=down_revision,
2423
+ file_path=file_path,
2424
+ start_line=1,
2425
+ end_line=len(content.splitlines()) if content else 1,
2426
+ created_tables=tuple(sorted(set(created_tables))),
2427
+ dropped_tables=tuple(sorted(set(dropped_tables))),
2428
+ added_columns=tuple(sorted(set(added_columns))),
2429
+ removed_columns=tuple(sorted(set(removed_columns))),
2430
+ renamed_columns=tuple(sorted(set(renamed_columns))),
2431
+ indexes=tuple(sorted(set(indexes))),
2432
+ foreign_keys=tuple(sorted(set(foreign_keys))),
2433
+ constraints=tuple(sorted(set(constraints))),
2434
+ affected_tables=tuple(sorted(affected_tables)),
2435
+ evidence=f"Alembic revision={revision!r}",
2436
+ )
2437
+ res.migrations.append(mig_fact)
2438
+ res.entities.append(
2439
+ DatabaseEntity(
2440
+ canonical_id=mig_cid,
2441
+ kind=DatabaseEntityKind.MIGRATION.value,
2442
+ name=revision,
2443
+ dialect=dialect,
2444
+ schema_name=schema,
2445
+ framework="alembic",
2446
+ file_path=file_path,
2447
+ start_line=1,
2448
+ end_line=mig_fact.end_line,
2449
+ evidence=f"Alembic revision={revision!r}",
2450
+ metadata=mig_fact.as_dict(),
2451
+ )
2452
+ )
2453
+ for tbl in sorted(affected_tables):
2454
+ tbl_cid = build_db_canonical_id(
2455
+ DatabaseEntityKind.TABLE,
2456
+ table=tbl,
2457
+ dialect=dialect,
2458
+ schema=schema,
2459
+ )
2460
+ res.edges.append(
2461
+ RelationshipRecord(
2462
+ source=mig_cid,
2463
+ target=tbl_cid,
2464
+ relationship="MIGRATES_TABLE",
2465
+ evidence_class="AST_VERIFIED",
2466
+ confidence="HIGH",
2467
+ file=file_path,
2468
+ start_line=1,
2469
+ end_line=mig_fact.end_line,
2470
+ evidence=f"Alembic migration {revision} affects {tbl}",
2471
+ status="FACT",
2472
+ parser_version=PARSER_VERSION,
2473
+ index_generation=index_generation,
2474
+ )
2475
+ )
2476
+
2477
+
2478
+ def _extract_django_migration(
2479
+ tree: ast.Module,
2480
+ content: str,
2481
+ file_path: str,
2482
+ module_name: str,
2483
+ dialect: str,
2484
+ schema: str,
2485
+ index_generation: int,
2486
+ res: DatabaseExtractionResult,
2487
+ ) -> None:
2488
+ revision = module_name.split(".")[-1]
2489
+ created_tables: list[str] = []
2490
+ dropped_tables: list[str] = []
2491
+ added_columns: list[str] = []
2492
+ removed_columns: list[str] = []
2493
+ renamed_columns: list[str] = []
2494
+ affected_tables: set[str] = set()
2495
+
2496
+ for child in ast.walk(tree):
2497
+ if not isinstance(child, ast.Call):
2498
+ continue
2499
+ cname = _ast_call_name(child).split(".")[-1]
2500
+ if cname == "CreateModel":
2501
+ m_name = _ast_const_str(_get_kwarg(child, "name"))
2502
+ if m_name:
2503
+ t_clean = _pluralize_table(_camel_to_snake(m_name))
2504
+ created_tables.append(t_clean)
2505
+ affected_tables.add(t_clean)
2506
+ fields_kw = _get_kwarg(child, "fields")
2507
+ if isinstance(fields_kw, ast.List):
2508
+ for elt in fields_kw.elts:
2509
+ if isinstance(elt, ast.Tuple) and elt.elts:
2510
+ col_nm = _ast_const_str(elt.elts[0])
2511
+ if col_nm:
2512
+ added_columns.append(f"{t_clean}.{col_nm.lower()}")
2513
+ elif cname == "DeleteModel":
2514
+ m_name = _ast_const_str(_get_kwarg(child, "name"))
2515
+ if m_name:
2516
+ t_clean = _pluralize_table(_camel_to_snake(m_name))
2517
+ dropped_tables.append(t_clean)
2518
+ affected_tables.add(t_clean)
2519
+ elif cname == "AddField":
2520
+ m_name = _ast_const_str(_get_kwarg(child, "model_name"))
2521
+ f_name = _ast_const_str(_get_kwarg(child, "name"))
2522
+ if m_name and f_name:
2523
+ t_clean = _pluralize_table(_camel_to_snake(m_name))
2524
+ added_columns.append(f"{t_clean}.{f_name.lower()}")
2525
+ affected_tables.add(t_clean)
2526
+ elif cname == "RemoveField":
2527
+ m_name = _ast_const_str(_get_kwarg(child, "model_name"))
2528
+ f_name = _ast_const_str(_get_kwarg(child, "name"))
2529
+ if m_name and f_name:
2530
+ t_clean = _pluralize_table(_camel_to_snake(m_name))
2531
+ removed_columns.append(f"{t_clean}.{f_name.lower()}")
2532
+ affected_tables.add(t_clean)
2533
+ elif cname == "RenameField":
2534
+ m_name = _ast_const_str(_get_kwarg(child, "model_name"))
2535
+ old_nm = _ast_const_str(_get_kwarg(child, "old_name"))
2536
+ new_nm = _ast_const_str(_get_kwarg(child, "new_name"))
2537
+ if m_name and old_nm and new_nm:
2538
+ t_clean = _pluralize_table(_camel_to_snake(m_name))
2539
+ renamed_columns.append(f"{t_clean}.{old_nm.lower()}->{new_nm.lower()}")
2540
+ affected_tables.add(t_clean)
2541
+
2542
+ mig_cid = build_db_canonical_id(
2543
+ DatabaseEntityKind.MIGRATION,
2544
+ name=revision,
2545
+ module="django",
2546
+ )
2547
+ mig_fact = MigrationFact(
2548
+ migration_id=revision,
2549
+ canonical_id=mig_cid,
2550
+ system="django",
2551
+ revision=revision,
2552
+ file_path=file_path,
2553
+ start_line=1,
2554
+ end_line=len(content.splitlines()) if content else 1,
2555
+ created_tables=tuple(sorted(set(created_tables))),
2556
+ dropped_tables=tuple(sorted(set(dropped_tables))),
2557
+ added_columns=tuple(sorted(set(added_columns))),
2558
+ removed_columns=tuple(sorted(set(removed_columns))),
2559
+ renamed_columns=tuple(sorted(set(renamed_columns))),
2560
+ affected_tables=tuple(sorted(affected_tables)),
2561
+ evidence=f"Django migration {revision}",
2562
+ evidence_class="FRAMEWORK_VERIFIED",
2563
+ )
2564
+ res.migrations.append(mig_fact)
2565
+ res.entities.append(
2566
+ DatabaseEntity(
2567
+ canonical_id=mig_cid,
2568
+ kind=DatabaseEntityKind.MIGRATION.value,
2569
+ name=revision,
2570
+ dialect=dialect,
2571
+ schema_name=schema,
2572
+ framework="django_orm",
2573
+ file_path=file_path,
2574
+ start_line=1,
2575
+ end_line=mig_fact.end_line,
2576
+ evidence=f"Django migration {revision}",
2577
+ evidence_class="FRAMEWORK_VERIFIED",
2578
+ metadata=mig_fact.as_dict(),
2579
+ )
2580
+ )
2581
+ for tbl in sorted(affected_tables):
2582
+ tbl_cid = build_db_canonical_id(
2583
+ DatabaseEntityKind.TABLE,
2584
+ table=tbl,
2585
+ dialect=dialect,
2586
+ schema=schema,
2587
+ )
2588
+ res.edges.append(
2589
+ RelationshipRecord(
2590
+ source=mig_cid,
2591
+ target=tbl_cid,
2592
+ relationship="MIGRATES_TABLE",
2593
+ evidence_class="FRAMEWORK_VERIFIED",
2594
+ confidence="HIGH",
2595
+ file=file_path,
2596
+ start_line=1,
2597
+ end_line=mig_fact.end_line,
2598
+ evidence=f"Django migration {revision} affects {tbl}",
2599
+ status="FACT",
2600
+ parser_version=PARSER_VERSION,
2601
+ index_generation=index_generation,
2602
+ )
2603
+ )
2604
+
2605
+
2606
+ def _extract_sql_file(
2607
+ content: str,
2608
+ file_path: str,
2609
+ dialect: str,
2610
+ schema: str,
2611
+ index_generation: int,
2612
+ res: DatabaseExtractionResult,
2613
+ ) -> None:
2614
+ parsed = parse_sql_statement(content)
2615
+ rev = file_path.rsplit("/", 1)[-1].replace(".sql", "")
2616
+ created_tables: list[str] = []
2617
+ added_columns: list[str] = []
2618
+ affected_tables: set[str] = set()
2619
+
2620
+ for op_info in parsed:
2621
+ op = str(op_info["operation"])
2622
+ if op == "CREATE_TABLE":
2623
+ for tbl in op_info.get("tables", []):
2624
+ created_tables.append(tbl)
2625
+ affected_tables.add(tbl)
2626
+ tbl_cid = build_db_canonical_id(
2627
+ DatabaseEntityKind.TABLE,
2628
+ table=tbl,
2629
+ dialect=dialect,
2630
+ schema=schema,
2631
+ )
2632
+ res.entities.append(
2633
+ DatabaseEntity(
2634
+ canonical_id=tbl_cid,
2635
+ kind=DatabaseEntityKind.TABLE.value,
2636
+ name=tbl,
2637
+ dialect=dialect,
2638
+ schema_name=schema,
2639
+ table_name=tbl,
2640
+ framework="sql",
2641
+ file_path=file_path,
2642
+ start_line=1,
2643
+ end_line=len(content.splitlines()),
2644
+ evidence=str(op_info.get("normalized_sql") or "")[:160],
2645
+ )
2646
+ )
2647
+ for cdef in op_info.get("column_defs", []):
2648
+ col_cid = build_db_canonical_id(
2649
+ DatabaseEntityKind.COLUMN,
2650
+ table=tbl,
2651
+ column=cdef["name"],
2652
+ dialect=dialect,
2653
+ schema=schema,
2654
+ )
2655
+ added_columns.append(f"{tbl}.{cdef['name']}")
2656
+ res.entities.append(
2657
+ DatabaseEntity(
2658
+ canonical_id=col_cid,
2659
+ kind=DatabaseEntityKind.COLUMN.value,
2660
+ name=cdef["name"],
2661
+ dialect=dialect,
2662
+ schema_name=schema,
2663
+ table_name=tbl,
2664
+ column_name=cdef["name"],
2665
+ data_type=cdef["data_type"],
2666
+ nullable=cdef["nullable"],
2667
+ is_primary_key=cdef["is_primary_key"],
2668
+ is_unique=cdef["is_unique"],
2669
+ framework="sql",
2670
+ file_path=file_path,
2671
+ start_line=1,
2672
+ end_line=len(content.splitlines()),
2673
+ evidence=f"{cdef['name']} {cdef['data_type']}",
2674
+ )
2675
+ )
2676
+ if cdef["is_primary_key"]:
2677
+ res.edges.append(
2678
+ RelationshipRecord(
2679
+ source=tbl_cid,
2680
+ target=col_cid,
2681
+ relationship="HAS_PRIMARY_KEY",
2682
+ evidence_class="AST_VERIFIED",
2683
+ confidence="HIGH",
2684
+ file=file_path,
2685
+ start_line=1,
2686
+ end_line=1,
2687
+ evidence=f"PRIMARY KEY ({cdef['name']})",
2688
+ status="FACT",
2689
+ parser_version=PARSER_VERSION,
2690
+ index_generation=index_generation,
2691
+ )
2692
+ )
2693
+ for fk in op_info.get("foreign_keys", []):
2694
+ src_col_cid = build_db_canonical_id(
2695
+ DatabaseEntityKind.COLUMN,
2696
+ table=tbl,
2697
+ column=fk["column"],
2698
+ dialect=dialect,
2699
+ schema=schema,
2700
+ )
2701
+ tgt_col_cid = build_db_canonical_id(
2702
+ DatabaseEntityKind.COLUMN,
2703
+ table=fk["target_table"],
2704
+ column=fk["target_column"],
2705
+ dialect=dialect,
2706
+ schema=schema,
2707
+ )
2708
+ res.edges.append(
2709
+ RelationshipRecord(
2710
+ source=src_col_cid,
2711
+ target=tgt_col_cid,
2712
+ relationship="FOREIGN_KEY_TO",
2713
+ evidence_class="AST_VERIFIED",
2714
+ confidence="HIGH",
2715
+ file=file_path,
2716
+ start_line=1,
2717
+ end_line=1,
2718
+ evidence=f"FOREIGN KEY ({fk['column']}) REFERENCES {fk['target_table']}({fk['target_column']})",
2719
+ status="FACT",
2720
+ parser_version=PARSER_VERSION,
2721
+ index_generation=index_generation,
2722
+ )
2723
+ )
2724
+ elif op == "CREATE_VIEW":
2725
+ vname = str(op_info.get("view_name") or "")
2726
+ if vname:
2727
+ vcid = build_db_canonical_id(
2728
+ DatabaseEntityKind.VIEW,
2729
+ table=vname,
2730
+ dialect=dialect,
2731
+ schema=schema,
2732
+ )
2733
+ res.entities.append(
2734
+ DatabaseEntity(
2735
+ canonical_id=vcid,
2736
+ kind=DatabaseEntityKind.VIEW.value,
2737
+ name=vname,
2738
+ dialect=dialect,
2739
+ schema_name=schema,
2740
+ table_name=vname,
2741
+ framework="sql",
2742
+ file_path=file_path,
2743
+ start_line=1,
2744
+ end_line=1,
2745
+ evidence=str(op_info.get("normalized_sql") or "")[:160],
2746
+ )
2747
+ )
2748
+ elif op == "CREATE_SEQUENCE":
2749
+ sname = str(op_info.get("sequence_name") or "")
2750
+ if sname:
2751
+ scid = build_db_canonical_id(
2752
+ DatabaseEntityKind.SEQUENCE,
2753
+ name=sname,
2754
+ dialect=dialect,
2755
+ schema=schema,
2756
+ )
2757
+ res.entities.append(
2758
+ DatabaseEntity(
2759
+ canonical_id=scid,
2760
+ kind=DatabaseEntityKind.SEQUENCE.value,
2761
+ name=sname,
2762
+ dialect=dialect,
2763
+ schema_name=schema,
2764
+ framework="sql",
2765
+ file_path=file_path,
2766
+ start_line=1,
2767
+ end_line=1,
2768
+ evidence=str(op_info.get("normalized_sql") or "")[:160],
2769
+ )
2770
+ )
2771
+ elif op == "ALTER_TABLE":
2772
+ for tbl in op_info.get("tables", []):
2773
+ affected_tables.add(tbl)
2774
+ for col in op_info.get("columns", []):
2775
+ added_columns.append(f"{tbl}.{col}")
2776
+
2777
+ if affected_tables:
2778
+ mig_cid = build_db_canonical_id(DatabaseEntityKind.MIGRATION, name=rev, module="sql")
2779
+ mig_fact = MigrationFact(
2780
+ migration_id=rev,
2781
+ canonical_id=mig_cid,
2782
+ system="sql",
2783
+ revision=rev,
2784
+ file_path=file_path,
2785
+ start_line=1,
2786
+ end_line=len(content.splitlines()),
2787
+ created_tables=tuple(sorted(set(created_tables))),
2788
+ added_columns=tuple(sorted(set(added_columns))),
2789
+ affected_tables=tuple(sorted(affected_tables)),
2790
+ evidence=f"SQL migration {file_path}",
2791
+ )
2792
+ res.migrations.append(mig_fact)
2793
+ for tbl in sorted(affected_tables):
2794
+ tbl_cid = build_db_canonical_id(
2795
+ DatabaseEntityKind.TABLE,
2796
+ table=tbl,
2797
+ dialect=dialect,
2798
+ schema=schema,
2799
+ )
2800
+ res.edges.append(
2801
+ RelationshipRecord(
2802
+ source=mig_cid,
2803
+ target=tbl_cid,
2804
+ relationship="MIGRATES_TABLE",
2805
+ evidence_class="AST_VERIFIED",
2806
+ confidence="HIGH",
2807
+ file=file_path,
2808
+ start_line=1,
2809
+ end_line=len(content.splitlines()),
2810
+ evidence=f"SQL migration {rev} affects {tbl}",
2811
+ status="FACT",
2812
+ parser_version=PARSER_VERSION,
2813
+ index_generation=index_generation,
2814
+ )
2815
+ )
2816
+
2817
+
2818
+ def _extract_prisma_schema(
2819
+ content: str,
2820
+ file_path: str,
2821
+ dialect: str,
2822
+ schema: str,
2823
+ index_generation: int,
2824
+ res: DatabaseExtractionResult,
2825
+ ) -> None:
2826
+ ds_m = re.search(r'datasource\s+\w+\s*\{[\s\S]*?provider\s*=\s*"([^"]+)"', content)
2827
+ if ds_m:
2828
+ dialect = normalize_dialect(ds_m.group(1))
2829
+
2830
+ for m in re.finditer(r"model\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}", content):
2831
+ model_name = m.group(1)
2832
+ body = m.group(2)
2833
+ model_line = content[: m.start()].count("\n") + 1
2834
+ map_m = re.search(r'@@map\(\s*"([^"]+)"\s*\)', body)
2835
+ tbl_name = (map_m.group(1) if map_m else _pluralize_table(_camel_to_snake(model_name))).lower()
2836
+ tbl_cid = build_db_canonical_id(
2837
+ DatabaseEntityKind.TABLE,
2838
+ table=tbl_name,
2839
+ dialect=dialect,
2840
+ schema=schema,
2841
+ )
2842
+ model_cid = f"prisma.{model_name}"
2843
+ res.entities.append(
2844
+ DatabaseEntity(
2845
+ canonical_id=model_cid,
2846
+ kind=DatabaseEntityKind.ORM_MODEL.value,
2847
+ name=model_name,
2848
+ dialect=dialect,
2849
+ schema_name=schema,
2850
+ table_name=tbl_name,
2851
+ orm_model_id=model_cid,
2852
+ framework="prisma",
2853
+ file_path=file_path,
2854
+ start_line=model_line,
2855
+ end_line=model_line,
2856
+ evidence=f"model {model_name}",
2857
+ evidence_class="FRAMEWORK_VERIFIED",
2858
+ )
2859
+ )
2860
+ res.entities.append(
2861
+ DatabaseEntity(
2862
+ canonical_id=tbl_cid,
2863
+ kind=DatabaseEntityKind.TABLE.value,
2864
+ name=tbl_name,
2865
+ dialect=dialect,
2866
+ schema_name=schema,
2867
+ table_name=tbl_name,
2868
+ orm_model_id=model_cid,
2869
+ framework="prisma",
2870
+ file_path=file_path,
2871
+ start_line=model_line,
2872
+ end_line=model_line,
2873
+ evidence=f"model {model_name}",
2874
+ evidence_class="FRAMEWORK_VERIFIED",
2875
+ )
2876
+ )
2877
+ res.edges.append(
2878
+ RelationshipRecord(
2879
+ source=model_cid,
2880
+ target=tbl_cid,
2881
+ relationship="MAPS_TO_TABLE",
2882
+ evidence_class="FRAMEWORK_VERIFIED",
2883
+ confidence="HIGH",
2884
+ file=file_path,
2885
+ start_line=model_line,
2886
+ end_line=model_line,
2887
+ evidence=f"model {model_name} -> {tbl_name}",
2888
+ status="FACT",
2889
+ parser_version=PARSER_VERSION,
2890
+ index_generation=index_generation,
2891
+ )
2892
+ )
2893
+
2894
+ # Pre-scan model body for @relation(fields: [...]) to accurately mark FK columns
2895
+ fk_fields: set[str] = set()
2896
+ for r_m in re.finditer(r'@relation\([\s\S]*?fields:\s*\[([A-Za-z0-9_,\s]+)\]', body):
2897
+ for fld in r_m.group(1).split(","):
2898
+ clean_fld = fld.strip()
2899
+ if clean_fld:
2900
+ fk_fields.add(clean_fld)
2901
+
2902
+ body_start_idx = m.start() + content[m.start() :].find("{") + 1
2903
+ current_offset = body_start_idx
2904
+ for line in body.splitlines():
2905
+ clean = line.strip()
2906
+ field_line = content[:current_offset].count("\n") + 1
2907
+ current_offset += len(line) + 1
2908
+ if not clean or clean.startswith(("//", "@@")):
2909
+ continue
2910
+ field_m = re.match(
2911
+ r"^([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z0-9_]+)(\?)?(\[\])?\s*(.*)$",
2912
+ clean,
2913
+ )
2914
+ if not field_m:
2915
+ continue
2916
+ field_name = field_m.group(1)
2917
+ raw_type = field_m.group(2)
2918
+ is_optional = bool(field_m.group(3))
2919
+ field_attrs = field_m.group(5)
2920
+
2921
+ if raw_type not in (
2922
+ "String", "Int", "BigInt", "Float", "Decimal", "Boolean",
2923
+ "DateTime", "Json", "Bytes",
2924
+ ) and "@relation" in field_attrs and "fields:" not in field_attrs:
2925
+ continue
2926
+
2927
+ map_field_m = re.search(r'@map\(\s*"([^"]+)"\s*\)', field_attrs)
2928
+ col_name = (map_field_m.group(1) if map_field_m else field_name).lower()
2929
+
2930
+ is_pk = bool("@id" in field_attrs or field_name == "id")
2931
+ is_unique = bool("@unique" in field_attrs)
2932
+ nullable = bool(is_optional)
2933
+
2934
+ def_m = re.search(r'@default\(([^)]*)\)', field_attrs)
2935
+ default_val = def_m.group(1).strip() if def_m else None
2936
+
2937
+ tgt_column = None
2938
+ fk_m = re.search(r'@relation\([\s\S]*?references:\s*\[([A-Za-z0-9_,\s]+)\]', field_attrs)
2939
+ if fk_m:
2940
+ tgt_col_raw = fk_m.group(1).split(",")[0].strip()
2941
+ tgt_column = tgt_col_raw
2942
+
2943
+ is_fk = bool(field_name in fk_fields or col_name in fk_fields or tgt_column)
2944
+
2945
+ col_cid = build_db_canonical_id(
2946
+ DatabaseEntityKind.COLUMN,
2947
+ table=tbl_name,
2948
+ column=col_name,
2949
+ dialect=dialect,
2950
+ schema=schema,
2951
+ )
2952
+
2953
+ res.entities.append(
2954
+ DatabaseEntity(
2955
+ canonical_id=col_cid,
2956
+ kind=DatabaseEntityKind.COLUMN.value,
2957
+ name=col_name,
2958
+ dialect=dialect,
2959
+ schema_name=schema,
2960
+ table_name=tbl_name,
2961
+ column_name=col_name,
2962
+ data_type=raw_type,
2963
+ nullable=nullable,
2964
+ default_value=default_val,
2965
+ is_primary_key=is_pk,
2966
+ is_foreign_key=is_fk,
2967
+ is_unique=is_unique,
2968
+ target_table=None,
2969
+ target_column=tgt_column,
2970
+ orm_model_id=model_cid,
2971
+ framework="prisma",
2972
+ file_path=file_path,
2973
+ start_line=field_line,
2974
+ end_line=field_line,
2975
+ evidence=clean,
2976
+ evidence_class="FRAMEWORK_VERIFIED",
2977
+ )
2978
+ )
2979
+
2980
+ res.edges.append(
2981
+ RelationshipRecord(
2982
+ source=tbl_cid,
2983
+ target=col_cid,
2984
+ relationship="MAPS_TO_COLUMN",
2985
+ evidence_class="FRAMEWORK_VERIFIED",
2986
+ confidence="HIGH",
2987
+ file=file_path,
2988
+ start_line=field_line,
2989
+ end_line=field_line,
2990
+ evidence=f"{tbl_name}.{col_name}",
2991
+ status="FACT",
2992
+ parser_version=PARSER_VERSION,
2993
+ index_generation=index_generation,
2994
+ )
2995
+ )
2996
+
2997
+
2998
+ _DRIZZLE_TABLE_RE = re.compile(
2999
+ r"\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(pgTable|mysqlTable|sqliteTable)\s*\(\s*['\"`]([^'\"`]+)['\"`]\s*,\s*\{"
3000
+ )
3001
+
3002
+
3003
+ def _extract_drizzle_schema(
3004
+ content: str,
3005
+ file_path: str,
3006
+ module_name: str,
3007
+ dialect: str,
3008
+ schema: str,
3009
+ index_generation: int,
3010
+ res: DatabaseExtractionResult,
3011
+ ) -> None:
3012
+ for m in _DRIZZLE_TABLE_RE.finditer(content):
3013
+ var_name = m.group(1)
3014
+ helper_fn = m.group(2)
3015
+ tbl_name = m.group(3).strip().lower()
3016
+
3017
+ start_pos = m.end() - 1
3018
+ depth = 0
3019
+ end_pos = start_pos
3020
+ for idx in range(start_pos, len(content)):
3021
+ c = content[idx]
3022
+ if c == "{":
3023
+ depth += 1
3024
+ elif c == "}":
3025
+ depth -= 1
3026
+ if depth == 0:
3027
+ end_pos = idx
3028
+ break
3029
+ body = content[start_pos + 1 : end_pos]
3030
+ line_no = content[: m.start()].count("\n") + 1
3031
+
3032
+ tbl_dialect = "postgres" if helper_fn == "pgTable" else ("mysql" if helper_fn == "mysqlTable" else "sqlite")
3033
+ tbl_cid = build_db_canonical_id(
3034
+ DatabaseEntityKind.TABLE,
3035
+ table=tbl_name,
3036
+ dialect=tbl_dialect,
3037
+ schema=schema,
3038
+ )
3039
+ model_cid = f"drizzle.{var_name}"
3040
+
3041
+ res.entities.append(
3042
+ DatabaseEntity(
3043
+ canonical_id=model_cid,
3044
+ kind=DatabaseEntityKind.ORM_MODEL.value,
3045
+ name=var_name,
3046
+ dialect=tbl_dialect,
3047
+ schema_name=schema,
3048
+ table_name=tbl_name,
3049
+ orm_model_id=model_cid,
3050
+ framework="drizzle",
3051
+ file_path=file_path,
3052
+ start_line=line_no,
3053
+ end_line=line_no,
3054
+ evidence=f"{var_name} = {helper_fn}('{tbl_name}')",
3055
+ evidence_class="FRAMEWORK_VERIFIED",
3056
+ )
3057
+ )
3058
+ res.entities.append(
3059
+ DatabaseEntity(
3060
+ canonical_id=tbl_cid,
3061
+ kind=DatabaseEntityKind.TABLE.value,
3062
+ name=tbl_name,
3063
+ dialect=tbl_dialect,
3064
+ schema_name=schema,
3065
+ table_name=tbl_name,
3066
+ orm_model_id=model_cid,
3067
+ framework="drizzle",
3068
+ file_path=file_path,
3069
+ start_line=line_no,
3070
+ end_line=line_no,
3071
+ evidence=f"{var_name} = {helper_fn}('{tbl_name}')",
3072
+ evidence_class="FRAMEWORK_VERIFIED",
3073
+ )
3074
+ )
3075
+ res.edges.append(
3076
+ RelationshipRecord(
3077
+ source=model_cid,
3078
+ target=tbl_cid,
3079
+ relationship="MAPS_TO_TABLE",
3080
+ evidence_class="FRAMEWORK_VERIFIED",
3081
+ confidence="HIGH",
3082
+ file=file_path,
3083
+ start_line=line_no,
3084
+ end_line=line_no,
3085
+ evidence=f"{var_name} -> {tbl_name}",
3086
+ status="FACT",
3087
+ parser_version=PARSER_VERSION,
3088
+ index_generation=index_generation,
3089
+ )
3090
+ )
3091
+
3092
+ # Parse column definitions supporting multi-line chained method calls
3093
+ col_entries = re.finditer(
3094
+ r"(?:^|\n)\s*([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)\s*\(([\s\S]*?)(?=(?:\n\s*[A-Za-z_$][\w$]*\s*:|\Z))",
3095
+ body,
3096
+ )
3097
+ for ce in col_entries:
3098
+ prop_key = ce.group(1)
3099
+ col_type = ce.group(2)
3100
+ rest = ce.group(3)
3101
+
3102
+ arg_m = re.match(r"^\s*['\"`]([^'\"`]+)['\"`]", rest)
3103
+ arg_col_name = arg_m.group(1) if arg_m else None
3104
+ col_name = (arg_col_name if arg_col_name else prop_key).lower()
3105
+
3106
+ is_pk = bool(".primaryKey(" in rest or prop_key == "id")
3107
+ is_unq = bool(".unique(" in rest)
3108
+ nullable = not bool(".notNull(" in rest or is_pk)
3109
+
3110
+ def_val = None
3111
+ if ".defaultNow(" in rest:
3112
+ def_val = "now()"
3113
+ elif ".default(" in rest:
3114
+ dm = re.search(r'\.default\(([^)]*)\)', rest)
3115
+ if dm:
3116
+ def_val = dm.group(1).strip()
3117
+
3118
+ tgt_tbl = None
3119
+ tgt_col = None
3120
+ if ".references(" in rest:
3121
+ ref_m = re.search(r'\.references\(\s*(?:\(\)\s*=>\s*)?([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)', rest)
3122
+ if ref_m:
3123
+ tgt_tbl = ref_m.group(1).lower()
3124
+ tgt_col = ref_m.group(2).lower()
3125
+
3126
+ col_cid = build_db_canonical_id(
3127
+ DatabaseEntityKind.COLUMN,
3128
+ table=tbl_name,
3129
+ column=col_name,
3130
+ dialect=tbl_dialect,
3131
+ schema=schema,
3132
+ )
3133
+ res.entities.append(
3134
+ DatabaseEntity(
3135
+ canonical_id=col_cid,
3136
+ kind=DatabaseEntityKind.COLUMN.value,
3137
+ name=col_name,
3138
+ dialect=tbl_dialect,
3139
+ schema_name=schema,
3140
+ table_name=tbl_name,
3141
+ column_name=col_name,
3142
+ data_type=col_type,
3143
+ nullable=nullable,
3144
+ default_value=def_val,
3145
+ is_primary_key=is_pk,
3146
+ is_foreign_key=bool(tgt_tbl),
3147
+ is_unique=is_unq,
3148
+ target_table=tgt_tbl,
3149
+ target_column=tgt_col,
3150
+ orm_model_id=model_cid,
3151
+ framework="drizzle",
3152
+ file_path=file_path,
3153
+ start_line=line_no,
3154
+ end_line=line_no,
3155
+ evidence=f"{prop_key}: {col_type}",
3156
+ evidence_class="FRAMEWORK_VERIFIED",
3157
+ )
3158
+ )
3159
+ res.edges.append(
3160
+ RelationshipRecord(
3161
+ source=tbl_cid,
3162
+ target=col_cid,
3163
+ relationship="MAPS_TO_COLUMN",
3164
+ evidence_class="FRAMEWORK_VERIFIED",
3165
+ confidence="HIGH",
3166
+ file=file_path,
3167
+ start_line=line_no,
3168
+ end_line=line_no,
3169
+ evidence=f"{tbl_name}.{col_name}",
3170
+ status="FACT",
3171
+ parser_version=PARSER_VERSION,
3172
+ index_generation=index_generation,
3173
+ )
3174
+ )
3175
+
3176
+
3177
+ def _extract_mongoose_schema(
3178
+ content: str,
3179
+ file_path: str,
3180
+ module_name: str,
3181
+ index_generation: int,
3182
+ res: DatabaseExtractionResult,
3183
+ model_to_table: dict[str, str],
3184
+ ) -> None:
3185
+ if "mongoose" not in content and "Schema" not in content and "model" not in content:
3186
+ return
3187
+
3188
+ res.detected_dialects.add("mongodb")
3189
+
3190
+ schema_blocks: dict[str, tuple[str, int, int]] = {}
3191
+ objects: dict[str, tuple[str, int, int]] = {}
3192
+
3193
+ # 1. Discover all top-level object declarations: const/let/var/export const <name>[: type] = { ... }
3194
+ for m in re.finditer(
3195
+ r"(?:(?:export\s+)?(?:const|let|var))\s+([A-Za-z_$][\w$]*)(?:\s*:\s*[^=;]+)?\s*=\s*\{",
3196
+ content,
3197
+ ):
3198
+ name = m.group(1)
3199
+ start = m.end() - 1
3200
+ depth = 0
3201
+ end = start
3202
+ for idx in range(start, len(content)):
3203
+ if content[idx] == "{":
3204
+ depth += 1
3205
+ elif content[idx] == "}":
3206
+ depth -= 1
3207
+ if depth == 0:
3208
+ end = idx
3209
+ break
3210
+ start_ln = content.count("\n", 0, m.start()) + 1
3211
+ end_ln = content.count("\n", 0, end) + 1
3212
+ objects[name] = (content[start + 1 : end], start_ln, end_ln)
3213
+
3214
+ # 2. Discover all Schema declarations: const/let/var/export <name>[: type] = new (mongoose.)?Schema(...)
3215
+ for m in re.finditer(
3216
+ r"(?:(?:export\s+)?(?:const|let|var)\s+)?([A-Za-z_$][\w$]*)(?:\s*:\s*[^=;]+)?\s*=\s*new\s+(?:mongoose\.)?Schema\s*(?:<[^>]+>)?\s*\(\s*([A-Za-z_$][\w$]*|\{)?",
3217
+ content,
3218
+ ):
3219
+ s_var = m.group(1)
3220
+ arg = m.group(2)
3221
+ if arg == "{":
3222
+ start_pos = m.end() - 1
3223
+ depth = 0
3224
+ end_pos = start_pos
3225
+ for idx in range(start_pos, len(content)):
3226
+ c = content[idx]
3227
+ if c == "{":
3228
+ depth += 1
3229
+ elif c == "}":
3230
+ depth -= 1
3231
+ if depth == 0:
3232
+ end_pos = idx
3233
+ break
3234
+ schema_body = content[start_pos + 1 : end_pos]
3235
+ start_ln = content.count("\n", 0, m.start()) + 1
3236
+ end_ln = content.count("\n", 0, end_pos) + 1
3237
+ schema_blocks[s_var] = (schema_body, start_ln, end_ln)
3238
+ elif arg and arg in objects:
3239
+ schema_blocks[s_var] = objects[arg]
3240
+
3241
+ for mm in re.finditer(
3242
+ r"(?:(?:(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)(?:\s*:\s*[^=;]+)?\s*=\s*)|(?:export\s+default\s+))?(?:mongoose\.)?model\s*(?:<[^>]+>)?\s*\(\s*['\"]([A-Za-z0-9_]+)['\"]\s*(?:,\s*([^\),\n]+))?",
3243
+ content,
3244
+ ):
3245
+ var_name = mm.group(1)
3246
+ model_name = mm.group(2)
3247
+ raw_schema_arg = (mm.group(3) or "").strip()
3248
+ schema_var = raw_schema_arg
3249
+
3250
+ coll_name = _pluralize_table(_camel_to_snake(model_name)).lower()
3251
+ if var_name:
3252
+ model_to_table[var_name] = coll_name
3253
+ model_to_table[model_name] = coll_name
3254
+ model_to_table[model_name.lower()] = coll_name
3255
+
3256
+ m_line = content.count("\n", 0, mm.start()) + 1
3257
+ tbl_cid = f"db.UNKNOWN.UNKNOWN.{coll_name}"
3258
+ mdl_cid = f"{tbl_cid}.{model_name}"
3259
+
3260
+ res.entities.append(
3261
+ DatabaseEntity(
3262
+ kind=DatabaseEntityKind.TABLE,
3263
+ dialect="mongodb",
3264
+ schema_name="UNKNOWN",
3265
+ table_name=coll_name,
3266
+ canonical_id=tbl_cid,
3267
+ name=coll_name,
3268
+ file_path=file_path,
3269
+ start_line=m_line,
3270
+ end_line=m_line,
3271
+ confidence="HIGH",
3272
+ evidence=redact_secrets(mm.group(0)),
3273
+ evidence_class="FRAMEWORK_VERIFIED",
3274
+ status="FACT",
3275
+ )
3276
+ )
3277
+
3278
+ res.entities.append(
3279
+ DatabaseEntity(
3280
+ kind=DatabaseEntityKind.ORM_MODEL,
3281
+ dialect="mongodb",
3282
+ schema_name="UNKNOWN",
3283
+ table_name=coll_name,
3284
+ canonical_id=mdl_cid,
3285
+ name=model_name,
3286
+ target_table=coll_name,
3287
+ file_path=file_path,
3288
+ start_line=m_line,
3289
+ end_line=m_line,
3290
+ confidence="HIGH",
3291
+ evidence=redact_secrets(mm.group(0)),
3292
+ evidence_class="FRAMEWORK_VERIFIED",
3293
+ status="FACT",
3294
+ )
3295
+ )
3296
+
3297
+ res.edges.append(
3298
+ RelationshipRecord(
3299
+ source=mdl_cid,
3300
+ target=tbl_cid,
3301
+ relationship="MAPS_TO_COLLECTION",
3302
+ confidence="HIGH",
3303
+ evidence_class="FRAMEWORK_VERIFIED",
3304
+ status="FACT",
3305
+ file=file_path,
3306
+ start_line=m_line,
3307
+ end_line=m_line,
3308
+ evidence=redact_secrets(mm.group(0)),
3309
+ reason=f"mongoose_model_to_collection:{coll_name}",
3310
+ parser_version=PARSER_VERSION,
3311
+ index_generation=index_generation,
3312
+ )
3313
+ )
3314
+
3315
+ # Resolve body: schema_blocks, objects, or inline
3316
+ body_tuple: tuple[str, int, int] | None = None
3317
+ if schema_var in schema_blocks:
3318
+ body_tuple = schema_blocks[schema_var]
3319
+ elif schema_var in objects:
3320
+ body_tuple = objects[schema_var]
3321
+ elif "{" in content[mm.start() : mm.start() + 200]:
3322
+ # Inline schema: new Schema({ ... }) or { ... }
3323
+ inline_start = content.find("{", mm.start())
3324
+ if inline_start != -1 and inline_start < mm.start() + 200:
3325
+ depth = 0
3326
+ end_pos = inline_start
3327
+ for idx in range(inline_start, len(content)):
3328
+ c = content[idx]
3329
+ if c == "{":
3330
+ depth += 1
3331
+ elif c == "}":
3332
+ depth -= 1
3333
+ if depth == 0:
3334
+ end_pos = idx
3335
+ break
3336
+ body_tuple = (
3337
+ content[inline_start + 1 : end_pos],
3338
+ content.count("\n", 0, inline_start) + 1,
3339
+ content.count("\n", 0, end_pos) + 1,
3340
+ )
3341
+
3342
+ if body_tuple is not None:
3343
+ body, s_start, s_end = body_tuple
3344
+ for f_match in re.finditer(
3345
+ r"([A-Za-z_$][\w$]*)\s*:\s*(?:\{\s*([^}]+)\}|([A-Za-z0-9_$.]+))",
3346
+ body,
3347
+ ):
3348
+ f_name = f_match.group(1)
3349
+ f_inner = f_match.group(2)
3350
+ f_direct = f_match.group(3)
3351
+ f_type = "Mixed"
3352
+ f_rest = ""
3353
+ if f_inner:
3354
+ type_m = re.search(r"type\s*:\s*([A-Za-z0-9_$.]+)", f_inner)
3355
+ f_type = type_m.group(1) if type_m else "Mixed"
3356
+ f_rest = f_inner
3357
+ elif f_direct:
3358
+ f_type = f_direct
3359
+
3360
+ col_cid = f"{tbl_cid}.{f_name}"
3361
+ is_req = bool(re.search(r"required\s*:\s*true", f_rest, re.IGNORECASE))
3362
+ is_uniq = bool(re.search(r"unique\s*:\s*true", f_rest, re.IGNORECASE))
3363
+ ref_match = re.search(r"ref\s*:\s*['\"]([A-Za-z0-9_]+)['\"]", f_rest)
3364
+ target_coll: str | None = None
3365
+ if ref_match:
3366
+ ref_model = ref_match.group(1)
3367
+ target_coll = _pluralize_table(_camel_to_snake(ref_model)).lower()
3368
+
3369
+ res.entities.append(
3370
+ DatabaseEntity(
3371
+ kind=DatabaseEntityKind.COLUMN,
3372
+ dialect="mongodb",
3373
+ schema_name="UNKNOWN",
3374
+ table_name=coll_name,
3375
+ column_name=f_name,
3376
+ data_type=f_type,
3377
+ nullable=bool(not is_req),
3378
+ is_primary_key=bool(f_name == "_id"),
3379
+ is_unique=bool(is_uniq),
3380
+ is_foreign_key=bool(target_coll is not None),
3381
+ target_table=target_coll,
3382
+ canonical_id=col_cid,
3383
+ name=f_name,
3384
+ file_path=file_path,
3385
+ start_line=s_start,
3386
+ end_line=s_end,
3387
+ confidence="HIGH",
3388
+ evidence=redact_secrets(f_match.group(0)),
3389
+ evidence_class="FRAMEWORK_VERIFIED",
3390
+ status="FACT",
3391
+ )
3392
+ )
3393
+
3394
+ res.edges.append(
3395
+ RelationshipRecord(
3396
+ source=tbl_cid,
3397
+ target=col_cid,
3398
+ relationship="MAPS_TO_COLUMN",
3399
+ confidence="HIGH",
3400
+ evidence_class="FRAMEWORK_VERIFIED",
3401
+ status="FACT",
3402
+ file=file_path,
3403
+ start_line=s_start,
3404
+ end_line=s_end,
3405
+ evidence=redact_secrets(f_match.group(0)),
3406
+ reason=f"mongoose_schema_field:{f_name}",
3407
+ parser_version=PARSER_VERSION,
3408
+ index_generation=index_generation,
3409
+ )
3410
+ )
3411
+
3412
+ if target_coll:
3413
+ res.edges.append(
3414
+ RelationshipRecord(
3415
+ source=col_cid,
3416
+ target=f"db.UNKNOWN.UNKNOWN.{target_coll}",
3417
+ relationship="FOREIGN_KEY_TO",
3418
+ confidence="HIGH",
3419
+ evidence_class="FRAMEWORK_VERIFIED",
3420
+ status="FACT",
3421
+ file=file_path,
3422
+ start_line=s_start,
3423
+ end_line=s_end,
3424
+ evidence=redact_secrets(f_match.group(0)),
3425
+ reason=f"mongoose_ref:{target_coll}",
3426
+ parser_version=PARSER_VERSION,
3427
+ index_generation=index_generation,
3428
+ )
3429
+ )
3430
+
3431
+
3432
+ def _extract_js_ts_database(
3433
+ content: str,
3434
+ file_path: str,
3435
+ module_name: str,
3436
+ dialect: str,
3437
+ schema: str,
3438
+ model_to_table: dict[str, str],
3439
+ index_generation: int,
3440
+ res: DatabaseExtractionResult,
3441
+ ) -> None:
3442
+ _extract_drizzle_schema(
3443
+ content,
3444
+ file_path,
3445
+ module_name,
3446
+ dialect,
3447
+ schema,
3448
+ index_generation,
3449
+ res,
3450
+ )
3451
+ _extract_mongoose_schema(
3452
+ content,
3453
+ file_path,
3454
+ module_name,
3455
+ index_generation,
3456
+ res,
3457
+ model_to_table,
3458
+ )
3459
+
3460
+ lines = content.splitlines()
3461
+ active_scope: str = module_name
3462
+
3463
+ for line_no, raw_line in enumerate(lines, start=1):
3464
+ ev = redact_secrets(raw_line.strip())
3465
+
3466
+ fn_match = re.search(
3467
+ r"(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>",
3468
+ raw_line,
3469
+ )
3470
+ if fn_match:
3471
+ fn_name = fn_match.group(1) or fn_match.group(2)
3472
+ if fn_name:
3473
+ active_scope = f"{module_name}.{fn_name}"
3474
+ else:
3475
+ meth_match = re.search(
3476
+ r"^\s*(?:public\s+|private\s+|protected\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\{",
3477
+ raw_line,
3478
+ )
3479
+ if meth_match:
3480
+ m_name = meth_match.group(1)
3481
+ if m_name not in ("if", "for", "while", "switch", "catch", "constructor"):
3482
+ active_scope = f"{module_name}.{m_name}"
3483
+
3484
+ # 1. Prisma Client: (this.|ctx.|db.)?prisma.<model>.<action>(...)
3485
+ for pm in re.finditer(
3486
+ r"\b(?:this\.|ctx\.|db\.)?prisma\.([a-zA-Z_][a-zA-Z0-9_]*)\.(findMany|findUnique|findUniqueOrThrow|findFirst|findFirstOrThrow|count|aggregate|groupBy|create|createMany|createManyAndReturn|update|updateMany|upsert|delete|deleteMany)\b",
3487
+ raw_line,
3488
+ ):
3489
+ model_prop = pm.group(1)
3490
+ method = pm.group(2)
3491
+ tbl = model_to_table.get(
3492
+ model_prop,
3493
+ model_to_table.get(
3494
+ model_prop.lower(),
3495
+ _pluralize_table(_camel_to_snake(model_prop)),
3496
+ ),
3497
+ ).lower()
3498
+
3499
+ is_read = method.startswith(("find", "count", "aggregate", "groupBy"))
3500
+ op = "SELECT" if is_read else ("INSERT" if "create" in method else ("DELETE" if "delete" in method else "UPDATE"))
3501
+ rel = "READS_TABLE" if is_read else "WRITES_TABLE"
3502
+ _emit_query_fact_and_edges(
3503
+ caller_id=active_scope,
3504
+ operation=op,
3505
+ relationship=rel,
3506
+ table_name=tbl,
3507
+ columns=(),
3508
+ read_columns=(),
3509
+ framework="prisma",
3510
+ normalized_sql=f"{op} {tbl}",
3511
+ dialect=dialect,
3512
+ schema=schema,
3513
+ file_path=file_path,
3514
+ start_line=line_no,
3515
+ end_line=line_no,
3516
+ evidence=ev,
3517
+ confidence="HIGH",
3518
+ evidence_class="FRAMEWORK_VERIFIED",
3519
+ status="FACT",
3520
+ index_generation=index_generation,
3521
+ res=res,
3522
+ )
3523
+
3524
+ # 2. Drizzle Query Builder: db.select().from(tableVar) / db.insert(tableVar) / db.update / db.delete
3525
+ for dm in re.finditer(
3526
+ r"\bdb(?:\.[a-zA-Z0-9_]+)?\.(select|insert|update|delete)\s*(?:\([^)]*\))?(?:\s*\.from\s*\(\s*([A-Za-z_$][\w$]*)\s*\)|\s*\(\s*([A-Za-z_$][\w$]*)\s*\))",
3527
+ raw_line,
3528
+ ):
3529
+ action = dm.group(1)
3530
+ table_var = dm.group(2) or dm.group(3)
3531
+ if table_var:
3532
+ tbl = model_to_table.get(table_var, table_var).lower()
3533
+ is_read = (action == "select")
3534
+ op = "SELECT" if is_read else ("INSERT" if action == "insert" else ("DELETE" if action == "delete" else "UPDATE"))
3535
+ rel = "READS_TABLE" if is_read else "WRITES_TABLE"
3536
+ _emit_query_fact_and_edges(
3537
+ caller_id=active_scope,
3538
+ operation=op,
3539
+ relationship=rel,
3540
+ table_name=tbl,
3541
+ columns=(),
3542
+ read_columns=(),
3543
+ framework="drizzle",
3544
+ normalized_sql=f"{op} {tbl}",
3545
+ dialect=dialect,
3546
+ schema=schema,
3547
+ file_path=file_path,
3548
+ start_line=line_no,
3549
+ end_line=line_no,
3550
+ evidence=ev,
3551
+ confidence="HIGH",
3552
+ evidence_class="FRAMEWORK_VERIFIED",
3553
+ status="FACT",
3554
+ index_generation=index_generation,
3555
+ res=res,
3556
+ )
3557
+
3558
+ # 3. Drizzle Relational Queries: db.query.<tableVar>.(findMany|findFirst)(...)
3559
+ for dqm in re.finditer(
3560
+ r"\bdb(?:\.[a-zA-Z0-9_]+)?\.query\.([A-Za-z_$][\w$]*)\.(findMany|findFirst)\b",
3561
+ raw_line,
3562
+ ):
3563
+ table_var = dqm.group(1)
3564
+ tbl = model_to_table.get(table_var, table_var).lower()
3565
+ _emit_query_fact_and_edges(
3566
+ caller_id=active_scope,
3567
+ operation="SELECT",
3568
+ relationship="READS_TABLE",
3569
+ table_name=tbl,
3570
+ columns=(),
3571
+ read_columns=(),
3572
+ framework="drizzle",
3573
+ normalized_sql=f"SELECT {tbl}",
3574
+ dialect=dialect,
3575
+ schema=schema,
3576
+ file_path=file_path,
3577
+ start_line=line_no,
3578
+ end_line=line_no,
3579
+ evidence=ev,
3580
+ confidence="HIGH",
3581
+ evidence_class="FRAMEWORK_VERIFIED",
3582
+ status="FACT",
3583
+ index_generation=index_generation,
3584
+ res=res,
3585
+ )
3586
+
3587
+ # 4. Knex: knex("users").select / insert / update / del
3588
+ for km in re.finditer(
3589
+ r'\bknex\(\s*["\']([a-zA-Z0-9_]+)["\']\s*\)\.(select|where|first|insert|update|del|delete)\b',
3590
+ raw_line,
3591
+ ):
3592
+ tbl = km.group(1).lower()
3593
+ method = km.group(2)
3594
+ is_read = method in ("select", "where", "first")
3595
+ op = "SELECT" if is_read else ("INSERT" if method == "insert" else ("DELETE" if method in ("del", "delete") else "UPDATE"))
3596
+ rel = "READS_TABLE" if is_read else "WRITES_TABLE"
3597
+ _emit_query_fact_and_edges(
3598
+ caller_id=active_scope,
3599
+ operation=op,
3600
+ relationship=rel,
3601
+ table_name=tbl,
3602
+ columns=(),
3603
+ read_columns=(),
3604
+ framework="knex",
3605
+ normalized_sql=f"{op} {tbl}",
3606
+ dialect=dialect,
3607
+ schema=schema,
3608
+ file_path=file_path,
3609
+ start_line=line_no,
3610
+ end_line=line_no,
3611
+ evidence=ev,
3612
+ confidence="HIGH",
3613
+ evidence_class="FRAMEWORK_VERIFIED",
3614
+ status="FACT",
3615
+ index_generation=index_generation,
3616
+ res=res,
3617
+ )
3618
+
3619
+ # 5. Mongoose Document Queries: Model.(find|findOne|findById|create|updateOne|deleteOne|...)
3620
+ for mgm in re.finditer(
3621
+ r"\b([A-Z][a-zA-Z0-9_]*)\.(find|findOne|findById|countDocuments|aggregate|create|insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|findByIdAndUpdate|findByIdAndDelete)\b",
3622
+ raw_line,
3623
+ ):
3624
+ model_ident = mgm.group(1)
3625
+ action = mgm.group(2)
3626
+ if model_ident in model_to_table or f"{model_ident.lower()}s" in model_to_table.values():
3627
+ tbl = model_to_table.get(model_ident, _pluralize_table(_camel_to_snake(model_ident))).lower()
3628
+ is_read = action.startswith(("find", "count", "aggregate"))
3629
+ op = "SELECT" if is_read else ("INSERT" if "create" in action or "insert" in action else ("DELETE" if "delete" in action else "UPDATE"))
3630
+ rel = "READS_COLLECTION" if is_read else "WRITES_COLLECTION"
3631
+ _emit_query_fact_and_edges(
3632
+ caller_id=active_scope,
3633
+ operation=op,
3634
+ relationship=rel,
3635
+ table_name=tbl,
3636
+ columns=(),
3637
+ read_columns=(),
3638
+ framework="mongoose",
3639
+ normalized_sql=f"{op} {tbl}",
3640
+ dialect="mongodb",
3641
+ schema="UNKNOWN",
3642
+ file_path=file_path,
3643
+ start_line=line_no,
3644
+ end_line=line_no,
3645
+ evidence=ev,
3646
+ confidence="HIGH",
3647
+ evidence_class="FRAMEWORK_VERIFIED",
3648
+ status="FACT",
3649
+ index_generation=index_generation,
3650
+ res=res,
3651
+ )