graph-dependency-analyzer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. graph_dependency_analyzer-0.2.0.dist-info/METADATA +158 -0
  2. graph_dependency_analyzer-0.2.0.dist-info/RECORD +86 -0
  3. graph_dependency_analyzer-0.2.0.dist-info/WHEEL +5 -0
  4. graph_dependency_analyzer-0.2.0.dist-info/entry_points.txt +2 -0
  5. graph_dependency_analyzer-0.2.0.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/application/__init__.py +0 -0
  8. src/application/dtos/__init__.py +0 -0
  9. src/application/dtos/affected_component.py +59 -0
  10. src/application/dtos/batch_processing_result.py +17 -0
  11. src/application/dtos/impact_analysis_response.py +56 -0
  12. src/application/dtos/impact_query_request.py +38 -0
  13. src/application/dtos/index_batch_request.py +53 -0
  14. src/application/dtos/index_batch_response.py +72 -0
  15. src/application/dtos/index_result.py +20 -0
  16. src/application/dtos/repository_index_result.py +79 -0
  17. src/application/exceptions.py +41 -0
  18. src/application/services/__init__.py +0 -0
  19. src/application/services/file_scanner_service.py +258 -0
  20. src/application/services/progress_tracker.py +132 -0
  21. src/application/use_cases/__init__.py +0 -0
  22. src/application/use_cases/clear_all_data_use_case.py +180 -0
  23. src/application/use_cases/index_batch_use_case.py +153 -0
  24. src/application/use_cases/index_repository_use_case.py +683 -0
  25. src/application/use_cases/indexing_orchestrator.py +181 -0
  26. src/application/use_cases/list_repositories_use_case.py +84 -0
  27. src/cli.py +512 -0
  28. src/config/__init__.py +5 -0
  29. src/config/container.py +211 -0
  30. src/config/logging_config.py +123 -0
  31. src/config/settings.py +165 -0
  32. src/domain/__init__.py +0 -0
  33. src/domain/entities/__init__.py +20 -0
  34. src/domain/entities/ast_node.py +32 -0
  35. src/domain/entities/code_chunk.py +45 -0
  36. src/domain/entities/code_file.py +44 -0
  37. src/domain/entities/dependency_graph.py +110 -0
  38. src/domain/entities/repository.py +46 -0
  39. src/domain/exceptions.py +36 -0
  40. src/domain/repositories/__init__.py +22 -0
  41. src/domain/repositories/i_code_parser.py +50 -0
  42. src/domain/repositories/i_dependency_extractor.py +35 -0
  43. src/domain/repositories/i_embedding_provider.py +31 -0
  44. src/domain/repositories/i_graph_repository.py +78 -0
  45. src/domain/repositories/i_vector_repository.py +55 -0
  46. src/domain/services/__init__.py +8 -0
  47. src/domain/services/dependency_extractor.py +962 -0
  48. src/domain/services/semantic_chunk_builder.py +719 -0
  49. src/domain/value_objects/__init__.py +12 -0
  50. src/domain/value_objects/chunk_type.py +24 -0
  51. src/domain/value_objects/dependency_type.py +22 -0
  52. src/domain/value_objects/node_type.py +22 -0
  53. src/domain/value_objects/qualified_name.py +78 -0
  54. src/infrastructure/__init__.py +0 -0
  55. src/infrastructure/exceptions.py +41 -0
  56. src/infrastructure/external_apis/__init__.py +0 -0
  57. src/infrastructure/external_apis/ollama_embedding_provider.py +134 -0
  58. src/infrastructure/external_apis/openai_embedding_provider.py +231 -0
  59. src/infrastructure/parsing/__init__.py +20 -0
  60. src/infrastructure/parsing/config_file_parser.py +347 -0
  61. src/infrastructure/parsing/gradle_dependency_parser.py +159 -0
  62. src/infrastructure/parsing/groovy_parser.py +198 -0
  63. src/infrastructure/parsing/maven_dependency_parser.py +147 -0
  64. src/infrastructure/parsing/microfrontend_config_parser.py +242 -0
  65. src/infrastructure/parsing/npm_package_dependency_parser.py +255 -0
  66. src/infrastructure/parsing/python_parser.py +211 -0
  67. src/infrastructure/parsing/sql_schema_parser.py +658 -0
  68. src/infrastructure/parsing/tree_sitter_java_parser.py +434 -0
  69. src/infrastructure/parsing/tree_sitter_typescript_parser.py +600 -0
  70. src/infrastructure/persistence/__init__.py +0 -0
  71. src/infrastructure/persistence/graph_export_repository.py +443 -0
  72. src/infrastructure/persistence/neo4j_dependency_repository.py +657 -0
  73. src/infrastructure/persistence/qdrant_vector_repository.py +357 -0
  74. src/infrastructure/persistence/repository_relationship_repository.py +1312 -0
  75. src/presentation/__init__.py +0 -0
  76. src/presentation/api/__init__.py +0 -0
  77. src/presentation/api/rest/__init__.py +0 -0
  78. src/presentation/api/rest/exception_handlers.py +174 -0
  79. src/presentation/api/rest/main.py +80 -0
  80. src/presentation/api/rest/routers/__init__.py +5 -0
  81. src/presentation/api/rest/routers/admin.py +103 -0
  82. src/presentation/api/rest/routers/analysis.py +980 -0
  83. src/presentation/api/rest/routers/export.py +442 -0
  84. src/presentation/api/rest/routers/health.py +117 -0
  85. src/presentation/api/rest/routers/indexing.py +148 -0
  86. src/presentation/api/rest/routers/relationships.py +1089 -0
@@ -0,0 +1,658 @@
1
+ """
2
+ SQL Schema Parser — generic multi-dialect database schema extractor.
3
+
4
+ Supports:
5
+ - Oracle PL/SQL (CREATE TABLE, CREATE OR REPLACE PROCEDURE/PACKAGE/VIEW/TRIGGER/FUNCTION)
6
+ - SQL Server T-SQL (CREATE TABLE [schema].[name], stored procedures)
7
+ - PostgreSQL (CREATE TABLE schema.name, CREATE FUNCTION, CREATE SEQUENCE)
8
+ - MySQL/MariaDB (CREATE TABLE `name`, ENGINE=InnoDB)
9
+ - ANSI SQL (Flyway/Liquibase migrations, ad-hoc DDL files)
10
+ - JPA/Hibernate (@Entity, @Table, @Document — extracted from Java source)
11
+ - MongoDB (@Document — Spring Data collection names)
12
+ - .properties (SQL queries embedded in properties files)
13
+
14
+ For each file it returns a ParseResult compatible with the existing indexing pipeline:
15
+ - classes → DB objects (DB_TABLE, DB_VIEW, DB_PROCEDURE, DB_PACKAGE, DB_TRIGGER, DB_SEQUENCE)
16
+ - imports → cross-schema / cross-table references (READS / WRITES / CALLS)
17
+ - method_calls → kept for pipeline compatibility (empty for SQL files)
18
+ - string_literals → raw SQL fragments for further analysis
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from src.domain.repositories.i_code_parser import ParseResult
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Result dataclass (dict-like so DependencyExtractor can process it uniformly)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ @dataclass
35
+ class DbObject:
36
+ """Represents a database object extracted from SQL source."""
37
+ object_type: str # DB_TABLE | DB_VIEW | DB_PROCEDURE | DB_PACKAGE | DB_TRIGGER | DB_SEQUENCE | DB_FUNCTION | DB_INDEX | DB_TYPE
38
+ name: str # simple name
39
+ schema: str # schema/user name (empty if not qualified)
40
+ qualified_name: str # schema.name or just name
41
+ dialect: str # oracle | sqlserver | postgresql | mysql | ansi
42
+ line: int = 0
43
+ columns: list[str] = field(default_factory=list) # for tables
44
+ references: list[dict] = field(default_factory=list) # cross-object refs
45
+ raw_ddl: str = ""
46
+
47
+ def to_class_dict(self) -> dict[str, Any]:
48
+ """Convert to the dict format DependencyExtractor expects from ParseResult.classes."""
49
+ return {
50
+ "name": self.name,
51
+ "qualified_name": self.qualified_name,
52
+ "node_type": self.object_type,
53
+ "schema": self.schema,
54
+ "dialect": self.dialect,
55
+ "columns": self.columns,
56
+ "line": self.line,
57
+ "metadata": {
58
+ "object_type": self.object_type,
59
+ "schema": self.schema,
60
+ "dialect": self.dialect,
61
+ "columns": self.columns,
62
+ },
63
+ }
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Dialect detection
68
+ # ---------------------------------------------------------------------------
69
+
70
+ _ORACLE_SIGNALS = re.compile(r'CREATE\s+OR\s+REPLACE|VARCHAR2|NUMBER\s*\(|DBMS_|SYSDATE|NVL\s*\(|ROWNUM|EXECUTE\s+IMMEDIATE', re.I)
71
+ _MSSQL_SIGNALS = re.compile(r'USE\s+\[|GO\s*$|\[dbo\]\.\[|\bIDENTITY\s*\(|NOCOUNT|\bGO\b', re.I | re.M)
72
+ _POSTGRES_SIGNALS= re.compile(r'SERIAL\b|BIGSERIAL\b|\$\$|LANGUAGE\s+plpgsql|\bRETURNING\b', re.I)
73
+ _MYSQL_SIGNALS = re.compile(r'ENGINE\s*=\s*InnoDB|AUTO_INCREMENT|`[^`]+`', re.I)
74
+
75
+
76
+ def _detect_dialect(content: str) -> str:
77
+ if _ORACLE_SIGNALS.search(content):
78
+ return "oracle"
79
+ if _MSSQL_SIGNALS.search(content):
80
+ return "sqlserver"
81
+ if _POSTGRES_SIGNALS.search(content):
82
+ return "postgresql"
83
+ if _MYSQL_SIGNALS.search(content):
84
+ return "mysql"
85
+ return "ansi"
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Name normalisation helpers
90
+ # ---------------------------------------------------------------------------
91
+
92
+ def _strip_quotes(name: str) -> str:
93
+ """Remove surrounding quotes/brackets from an identifier."""
94
+ name = name.strip()
95
+ if (name.startswith('"') and name.endswith('"')) or \
96
+ (name.startswith("'") and name.endswith("'")):
97
+ return name[1:-1]
98
+ if name.startswith("[") and name.endswith("]"):
99
+ return name[1:-1]
100
+ if name.startswith("`") and name.endswith("`"):
101
+ return name[1:-1]
102
+ return name
103
+
104
+
105
+ def _qualified(schema: str, name: str) -> str:
106
+ schema = _strip_quotes(schema).upper() if schema else ""
107
+ name = _strip_quotes(name).upper()
108
+ return f"{schema}.{name}" if schema else name
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Regex patterns for DDL extraction
113
+ # ---------------------------------------------------------------------------
114
+
115
+ # Identifier: quoted or unquoted
116
+ _ID = r'(?:"[^"]+"|\'[^\']+\'|\[[^\]]+\]|`[^`]+`|[\w$#]+)'
117
+
118
+ # Optional schema prefix: schema.name or [schema].[name]
119
+ _SCHEMA_NAME = rf'(?:({_ID})\s*\.\s*)?({_ID})'
120
+
121
+ _RE_CREATE_TABLE = re.compile(
122
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?(?:GLOBAL\s+TEMPORARY\s+|TEMP\s+|TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?{_SCHEMA_NAME}',
123
+ re.I,
124
+ )
125
+ _RE_CREATE_VIEW = re.compile(
126
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?(?:FORCE\s+)?(?:MATERIALIZED\s+)?VIEW\s+{_SCHEMA_NAME}',
127
+ re.I,
128
+ )
129
+ _RE_CREATE_PROCEDURE = re.compile(
130
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?PROCEDURE\s+{_SCHEMA_NAME}',
131
+ re.I,
132
+ )
133
+ _RE_CREATE_FUNCTION = re.compile(
134
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+{_SCHEMA_NAME}',
135
+ re.I,
136
+ )
137
+ _RE_CREATE_PACKAGE = re.compile(
138
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?PACKAGE\s+(?:BODY\s+)?{_SCHEMA_NAME}',
139
+ re.I,
140
+ )
141
+ _RE_CREATE_TRIGGER = re.compile(
142
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?TRIGGER\s+{_SCHEMA_NAME}',
143
+ re.I,
144
+ )
145
+ _RE_CREATE_SEQUENCE = re.compile(
146
+ rf'CREATE\s+SEQUENCE\s+{_SCHEMA_NAME}',
147
+ re.I,
148
+ )
149
+ _RE_CREATE_INDEX = re.compile(
150
+ rf'CREATE\s+(?:UNIQUE\s+|BITMAP\s+)?INDEX\s+{_SCHEMA_NAME}\s+ON\s+{_SCHEMA_NAME}',
151
+ re.I,
152
+ )
153
+ _RE_CREATE_TYPE = re.compile(
154
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?TYPE\s+{_SCHEMA_NAME}',
155
+ re.I,
156
+ )
157
+ _RE_CREATE_SYNONYM = re.compile(
158
+ rf'CREATE\s+(?:OR\s+REPLACE\s+)?(?:PUBLIC\s+)?SYNONYM\s+{_SCHEMA_NAME}',
159
+ re.I,
160
+ )
161
+
162
+ # DML references inside SQL bodies
163
+ _RE_FROM = re.compile(rf'(?:FROM|JOIN)\s+{_SCHEMA_NAME}', re.I)
164
+ _RE_INTO = re.compile(rf'INSERT\s+INTO\s+{_SCHEMA_NAME}', re.I)
165
+ _RE_UPDATE = re.compile(rf'UPDATE\s+{_SCHEMA_NAME}', re.I)
166
+ _RE_DELETE = re.compile(rf'DELETE\s+FROM\s+{_SCHEMA_NAME}', re.I)
167
+ _RE_CALL = re.compile(rf'(?:CALL|EXEC(?:UTE)?)\s+{_SCHEMA_NAME}', re.I)
168
+
169
+ # Column extraction from CREATE TABLE body
170
+ _RE_COLUMN = re.compile(r'^\s+["`\[]?(\w+)["`\]]?\s+(?:VARCHAR|NVARCHAR|VARCHAR2|CHAR|NCHAR|TEXT|INT|BIGINT|SMALLINT|NUMERIC|NUMBER|DECIMAL|FLOAT|REAL|DOUBLE|DATE|DATETIME|TIMESTAMP|BOOLEAN|BOOL|CLOB|BLOB|JSONB?|UUID|UNIQUEIDENTIFIER)', re.I | re.M)
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Java source parsing (JPA @Table/@Entity, Spring Data @Document)
175
+ # ---------------------------------------------------------------------------
176
+
177
+ _RE_JPA_ENTITY = re.compile(r'@Entity\b', re.I)
178
+ _RE_JPA_TABLE = re.compile(r'@Table\s*\(\s*(?:name\s*=\s*)?["\']([^"\']+)["\']', re.I)
179
+ _RE_MONGO_DOC = re.compile(r'@Document\s*\(\s*(?:collection\s*=\s*)?["\']([^"\']+)["\']', re.I)
180
+ _RE_CLASS_NAME = re.compile(r'(?:public|protected|private)?\s*(?:class|interface)\s+(\w+)', re.I)
181
+
182
+ # JPA relationship annotations
183
+ # @JoinColumn(name="template_id") → FK column linking two tables
184
+ _RE_JOIN_COLUMN = re.compile(r'@JoinColumn\s*\([^)]*name\s*=\s*["\']([^"\']+)["\']', re.I)
185
+ # @ManyToOne / @OneToOne + field type → linked entity class name
186
+ _RE_MANY_TO_ONE = re.compile(r'@(?:ManyToOne|OneToOne)\b[^;]*?\n\s*(?:private|protected|public)?\s*([\w<>]+)\s+\w+\s*;', re.I | re.S)
187
+ # @OneToMany(mappedBy="fieldName") → inverse side of relation
188
+ _RE_ONE_TO_MANY = re.compile(r'@OneToMany\s*\([^)]*mappedBy\s*=\s*["\']([^"\']+)["\']', re.I)
189
+ # @ManyToMany join table
190
+ _RE_MANY_TO_MANY = re.compile(r'@ManyToMany\b', re.I)
191
+ # @CollectionTable(name="table_name", ...) → separate table for @ElementCollection
192
+ # We match only the FIRST name= attribute inside @CollectionTable's own parens,
193
+ # stopping before any nested annotation like @JoinColumn
194
+ _RE_COLLECTION_TABLE = re.compile(
195
+ r'@CollectionTable\s*\(\s*\n?\s*name\s*=\s*["\']([^"\']+)["\']',
196
+ re.I,
197
+ )
198
+ # @Column(name="col") on a field — extract field-to-column mapping
199
+ _RE_COLUMN_NAME = re.compile(r'@Column\s*\([^)]*name\s*=\s*["\']([^"\']+)["\']', re.I)
200
+ # @NamedNativeQuery / @Query with native SQL
201
+ _RE_NATIVE_QUERY = re.compile(r'(?:nativeQuery\s*=\s*true|@NamedNativeQuery)[^"\']*["\']([^"\']{10,500})["\']', re.I | re.S)
202
+ # @Query("SELECT ... FROM tableName ...") (JPQL — entity class names, not table names)
203
+ _RE_JPQL_FROM = re.compile(r'FROM\s+([A-Z][a-zA-Z0-9]+)(?:\s+\w+)?(?:\s+WHERE|\s+JOIN|\s+ORDER|\s+GROUP|\)|\s*$)', re.M)
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Properties file SQL parsing
208
+ # ---------------------------------------------------------------------------
209
+
210
+ # Schema placeholder patterns used in SQL properties files:
211
+ # #schema#.TABLE → FROM #schema#.OUTORDHDR
212
+ # {schema}.TABLE → FROM {schema}.OUTORDHDR
213
+ # ${schemaName}.TABLE
214
+ # [schema].[TABLE] (SQL Server)
215
+ _SCHEMA_PLACEHOLDER = r'(?:(?:#\w+#|\$\{\w+\}|\$\w+|\{\w+\}|\[\w+\]|\w+)\s*\.\s*)?'
216
+
217
+ _RE_PROP_SQL_TABLE = re.compile(
218
+ rf'(?:FROM|JOIN|INTO|UPDATE|TABLE)\s+{_SCHEMA_PLACEHOLDER}({_ID})',
219
+ re.I,
220
+ )
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Main parser class
225
+ # ---------------------------------------------------------------------------
226
+
227
+ class SqlSchemaParser:
228
+ """
229
+ Generic SQL/DB schema parser.
230
+
231
+ Handles .sql, .pks, .pkb, .prc, .fnc, .trg, .vw, .tbl files plus
232
+ Java files for JPA/Mongo annotations, and .properties files for
233
+ embedded SQL queries.
234
+ """
235
+
236
+ SQL_EXTENSIONS = {".sql", ".SQL", ".pks", ".pkb", ".prc", ".fnc",
237
+ ".trg", ".vw", ".tbl", ".seq", ".idx", ".typ"}
238
+ JAVA_EXTENSIONS = {".java"}
239
+ PROP_EXTENSIONS = {".properties"}
240
+
241
+ def parse_file(self, file_path: Path) -> ParseResult:
242
+ """
243
+ Parse a DB-related source file.
244
+
245
+ Returns ParseResult where:
246
+ - classes → list[dict] from DbObject.to_class_dict()
247
+ - imports → list[dict] with cross-object references
248
+ - method_calls→ [] (unused for SQL)
249
+ - string_literals → raw SQL fragments
250
+ """
251
+ if not file_path.exists():
252
+ return self._empty(file_path)
253
+
254
+ try:
255
+ content = file_path.read_text(encoding="utf-8", errors="replace")
256
+ except Exception:
257
+ return self._empty(file_path)
258
+
259
+ ext = file_path.suffix.lower()
260
+
261
+ if ext in self.JAVA_EXTENSIONS:
262
+ return self._parse_java(file_path, content)
263
+ if ext in self.PROP_EXTENSIONS:
264
+ return self._parse_properties(file_path, content)
265
+ # Default: SQL file
266
+ return self._parse_sql(file_path, content)
267
+
268
+ # ------------------------------------------------------------------
269
+ # SQL parsing
270
+ # ------------------------------------------------------------------
271
+
272
+ # Strip SQL comments before scanning for DML references
273
+ _RE_BLOCK_COMMENT = re.compile(r'/\*.*?\*/', re.S)
274
+ _RE_LINE_COMMENT = re.compile(r'--[^\n]*')
275
+
276
+ @classmethod
277
+ def _strip_comments(cls, content: str) -> str:
278
+ """Remove SQL comments so we don't extract table names from them."""
279
+ content = cls._RE_BLOCK_COMMENT.sub(' ', content)
280
+ content = cls._RE_LINE_COMMENT.sub(' ', content)
281
+ return content
282
+
283
+ def _parse_sql(self, file_path: Path, content: str) -> ParseResult:
284
+ # Skip binary/UCS-2 files (some old SQL Server dumps)
285
+ if "\x00" in content[:200]:
286
+ return self._empty(file_path)
287
+
288
+ dialect = _detect_dialect(content)
289
+ objects: list[DbObject] = []
290
+ references: list[dict] = []
291
+
292
+ # Use comment-stripped content for DML ref scanning only
293
+ # (DDL uses full content so line numbers stay valid)
294
+ stripped = self._strip_comments(content)
295
+
296
+ def line_of(match) -> int:
297
+ return content[:match.start()].count("\n") + 1
298
+
299
+ # ── DDL objects ─────────────────────────────────────────────
300
+
301
+ for m in _RE_CREATE_TABLE.finditer(content):
302
+ schema, name = m.group(1), m.group(2)
303
+ obj = DbObject(
304
+ object_type="DB_TABLE",
305
+ name=_strip_quotes(name),
306
+ schema=_strip_quotes(schema) if schema else "",
307
+ qualified_name=_qualified(schema, name),
308
+ dialect=dialect,
309
+ line=line_of(m),
310
+ )
311
+ # Extract column names from the table body
312
+ body_start = content.find("(", m.end())
313
+ if body_start != -1:
314
+ body_end = self._find_matching_paren(content, body_start)
315
+ body = content[body_start:body_end]
316
+ obj.columns = [
317
+ c.group(1) for c in _RE_COLUMN.finditer(body)
318
+ ]
319
+ obj.raw_ddl = f"CREATE TABLE {obj.qualified_name}" + body[:300]
320
+ objects.append(obj)
321
+
322
+ for pattern, obj_type in [
323
+ (_RE_CREATE_VIEW, "DB_VIEW"),
324
+ (_RE_CREATE_PROCEDURE, "DB_PROCEDURE"),
325
+ (_RE_CREATE_FUNCTION, "DB_FUNCTION"),
326
+ (_RE_CREATE_PACKAGE, "DB_PACKAGE"),
327
+ (_RE_CREATE_TRIGGER, "DB_TRIGGER"),
328
+ (_RE_CREATE_SEQUENCE, "DB_SEQUENCE"),
329
+ (_RE_CREATE_TYPE, "DB_TYPE"),
330
+ (_RE_CREATE_SYNONYM, "DB_SYNONYM"),
331
+ ]:
332
+ for m in pattern.finditer(content):
333
+ schema, name = m.group(1), m.group(2)
334
+ objects.append(DbObject(
335
+ object_type=obj_type,
336
+ name=_strip_quotes(name),
337
+ schema=_strip_quotes(schema) if schema else "",
338
+ qualified_name=_qualified(schema, name),
339
+ dialect=dialect,
340
+ line=line_of(m),
341
+ ))
342
+
343
+ # Index: name ON table
344
+ for m in _RE_CREATE_INDEX.finditer(content):
345
+ idx_schema, idx_name = m.group(1), m.group(2)
346
+ tbl_schema, tbl_name = m.group(3), m.group(4)
347
+ objects.append(DbObject(
348
+ object_type="DB_INDEX",
349
+ name=_strip_quotes(idx_name),
350
+ schema=_strip_quotes(idx_schema) if idx_schema else "",
351
+ qualified_name=_qualified(idx_schema, idx_name),
352
+ dialect=dialect,
353
+ line=line_of(m),
354
+ ))
355
+ references.append({
356
+ "type": "INDEXES",
357
+ "target": _qualified(tbl_schema, tbl_name),
358
+ })
359
+
360
+ # Deduplicate DDL objects: same (object_type, qualified_name) — keep first
361
+ seen_objs: set[tuple] = set()
362
+ unique_objects: list[DbObject] = []
363
+ for o in objects:
364
+ key = (o.object_type, o.qualified_name)
365
+ if key not in seen_objs:
366
+ seen_objs.add(key)
367
+ unique_objects.append(o)
368
+ objects = unique_objects
369
+
370
+ # ── DML cross-references ────────────────────────────────────
371
+
372
+ # Known Oracle/SQL Server schema names that should NOT be treated as table names
373
+ _KNOWN_SCHEMAS = {
374
+ "ZMEDAPP", "CATNGS", "DBB01USR", "CMOP", "ZMEDTRANS", "PCAT",
375
+ "PETL", "ECC", "NGSUSR", "ZCATMON", "WMS_PROV", "TRAX7EDU",
376
+ "DBO", "SYS", "SYSTEM", "PUBLIC", "INFORMATION_SCHEMA",
377
+ "MASTER", "TEMPDB", "MODEL", "MSDB",
378
+ }
379
+
380
+ _SQL_KEYWORDS = {
381
+ "NULL", "DUAL", "SYSDATE", "USER", "ROWNUM", "LEVEL",
382
+ "CONNECT", "START", "WITH", "VALUES", "SELECT", "WHERE",
383
+ "SET", "AS", "ON", "BY", "TO", "FROM", "INTO", "OF",
384
+ "ALL", "ANY", "SOME", "EXISTS", "NOT", "AND", "OR",
385
+ "CASE", "WHEN", "THEN", "ELSE", "END", "IN", "BETWEEN",
386
+ "LIKE", "IS", "HAVING", "GROUP", "ORDER", "LIMIT",
387
+ "FETCH", "NEXT", "ROWS", "ONLY", "UNION", "EXCEPT",
388
+ "INTERSECT", "MINUS", "PIVOT", "UNPIVOT",
389
+ }
390
+
391
+ def add_ref(ref_type: str, schema: str | None, name: str, line: int):
392
+ raw_name = _strip_quotes(name).upper()
393
+ # Skip SQL keywords
394
+ if raw_name in _SQL_KEYWORDS:
395
+ return
396
+ # Skip if the "name" is actually a known schema (no dot was matched)
397
+ if not schema and raw_name in _KNOWN_SCHEMAS:
398
+ return
399
+ # Skip very short names that are likely aliases
400
+ if len(raw_name) < 2:
401
+ return
402
+ qn = _qualified(schema, name)
403
+ references.append({
404
+ "type": ref_type,
405
+ "target": qn,
406
+ "line": line,
407
+ })
408
+
409
+ for pattern, ref_type in [
410
+ (_RE_FROM, "READS_TABLE"),
411
+ (_RE_INTO, "WRITES_TABLE"),
412
+ (_RE_UPDATE, "WRITES_TABLE"),
413
+ (_RE_DELETE, "WRITES_TABLE"),
414
+ (_RE_CALL, "CALLS_PROCEDURE"),
415
+ ]:
416
+ for m in pattern.finditer(stripped): # use comment-stripped content
417
+ add_ref(ref_type, m.group(1), m.group(2), 0)
418
+
419
+ # Deduplicate references (same type + target)
420
+ seen_refs: set[tuple] = set()
421
+ unique_refs: list[dict] = []
422
+ for ref in references:
423
+ key = (ref["type"], ref["target"])
424
+ if key not in seen_refs:
425
+ seen_refs.add(key)
426
+ unique_refs.append(ref)
427
+
428
+ return ParseResult(
429
+ file_path=file_path,
430
+ classes=[o.to_class_dict() for o in objects],
431
+ imports=unique_refs,
432
+ method_calls=[],
433
+ string_literals=self._extract_sql_strings(content),
434
+ )
435
+
436
+ def _find_matching_paren(self, content: str, start: int) -> int:
437
+ """Find the closing ) for the opening ( at start."""
438
+ depth = 0
439
+ for i in range(start, min(start + 8000, len(content))):
440
+ if content[i] == "(":
441
+ depth += 1
442
+ elif content[i] == ")":
443
+ depth -= 1
444
+ if depth == 0:
445
+ return i + 1
446
+ return start + 500 # fallback
447
+
448
+ def _extract_sql_strings(self, content: str) -> list[str]:
449
+ """Extract single-quoted string literals from SQL."""
450
+ return re.findall(r"'([^']{3,200})'", content)
451
+
452
+ # ------------------------------------------------------------------
453
+ # Java source: JPA + Spring Data MongoDB
454
+ # ------------------------------------------------------------------
455
+
456
+ def _parse_java(self, file_path: Path, content: str) -> ParseResult:
457
+ objects: list[DbObject] = []
458
+ references: list[dict] = []
459
+
460
+ is_entity = bool(_RE_JPA_ENTITY.search(content))
461
+
462
+ # Primary table name for this entity (used as source in relationship refs)
463
+ primary_table: str | None = None
464
+
465
+ # JPA @Table(name="...")
466
+ for m in _RE_JPA_TABLE.finditer(content):
467
+ table_name = m.group(1)
468
+ qn = table_name.upper()
469
+ objects.append(DbObject(
470
+ object_type="DB_TABLE",
471
+ name=table_name,
472
+ schema="",
473
+ qualified_name=qn,
474
+ dialect="jpa",
475
+ line=content[:m.start()].count("\n") + 1,
476
+ ))
477
+ if primary_table is None:
478
+ primary_table = qn
479
+
480
+ # @Entity without explicit @Table — infer table name from class name
481
+ if is_entity and not primary_table:
482
+ cm = _RE_CLASS_NAME.search(content)
483
+ if cm:
484
+ table_name = _camel_to_snake(cm.group(1))
485
+ qn = table_name.upper()
486
+ objects.append(DbObject(
487
+ object_type="DB_TABLE",
488
+ name=table_name,
489
+ schema="",
490
+ qualified_name=qn,
491
+ dialect="jpa",
492
+ ))
493
+ primary_table = qn
494
+
495
+ # ── JPA relationship annotations ──────────────────────────────────
496
+
497
+ if is_entity and primary_table:
498
+ seen_refs: set[str] = set()
499
+
500
+ def add_ref(ref_type: str, target: str) -> None:
501
+ t = target.upper().strip()
502
+ if t and t not in seen_refs and len(t) > 1:
503
+ seen_refs.add(t)
504
+ references.append({"type": ref_type, "target": t, "line": 0})
505
+
506
+ # @CollectionTable(name="...") → separate table owned by this entity
507
+ for m in _RE_COLLECTION_TABLE.finditer(content):
508
+ tbl = m.group(1)
509
+ # Create the collection table as a DB_TABLE object too
510
+ qn = tbl.upper()
511
+ objects.append(DbObject(
512
+ object_type="DB_TABLE",
513
+ name=tbl,
514
+ schema="",
515
+ qualified_name=qn,
516
+ dialect="jpa",
517
+ line=content[:m.start()].count("\n") + 1,
518
+ ))
519
+ add_ref("READS_TABLE", tbl)
520
+
521
+ # Java built-in types that are NOT entity class names
522
+ _JAVA_BUILTINS = {
523
+ "STRING", "INTEGER", "LONG", "BOOLEAN", "DOUBLE", "FLOAT",
524
+ "BYTE", "SHORT", "CHARACTER", "OBJECT", "LIST", "MAP",
525
+ "SET", "COLLECTION", "OPTIONAL", "INSTANT", "LOCALDATE",
526
+ "LOCALDATETIME", "BIGDECIMAL", "UUID", "ENUM",
527
+ }
528
+
529
+ # @ManyToOne / @OneToOne — extract the referenced entity class,
530
+ # convert to snake_case table name (JPA default convention)
531
+ for m in _RE_MANY_TO_ONE.finditer(content):
532
+ entity_class = m.group(1).strip()
533
+ # Strip generic like List<Foo> → Foo
534
+ entity_class = re.sub(r'.*<(.+)>', r'\1', entity_class)
535
+ entity_class = entity_class.strip()
536
+ if (entity_class and entity_class[0].isupper()
537
+ and entity_class.upper() not in _JAVA_BUILTINS):
538
+ table_name = _camel_to_snake(entity_class).upper()
539
+ add_ref("READS_TABLE", table_name)
540
+
541
+ # @JoinColumn(name="fk_col") → FK linking to another table
542
+ for m in _RE_JOIN_COLUMN.finditer(content):
543
+ col_name = m.group(1)
544
+ # The col name itself isn't a table — skip (we already get it via @ManyToOne)
545
+ # But record it as a column reference for traceability
546
+ pass # kept for future column-level analysis
547
+
548
+ # @Query with nativeQuery=true — extract raw SQL table refs
549
+ for m in _RE_NATIVE_QUERY.finditer(content):
550
+ sql_fragment = m.group(1)
551
+ for tm in _RE_PROP_SQL_TABLE.finditer(sql_fragment):
552
+ raw = (tm.group(1) or "").upper().strip()
553
+ if raw and len(raw) > 2 and raw not in self._PROP_SKIP_NAMES:
554
+ add_ref("READS_TABLE", raw)
555
+
556
+ # JPQL @Query FROM EntityClass — map entity class to table name
557
+ for m in _RE_JPQL_FROM.finditer(content):
558
+ entity_class = m.group(1).strip()
559
+ if entity_class and entity_class[0].isupper():
560
+ table_name = _camel_to_snake(entity_class).upper()
561
+ add_ref("READS_TABLE", table_name)
562
+
563
+ # ── MongoDB @Document ──────────────────────────────────────────────
564
+ for m in _RE_MONGO_DOC.finditer(content):
565
+ coll_name = m.group(1)
566
+ objects.append(DbObject(
567
+ object_type="DB_COLLECTION",
568
+ name=coll_name,
569
+ schema="",
570
+ qualified_name=coll_name,
571
+ dialect="mongodb",
572
+ line=content[:m.start()].count("\n") + 1,
573
+ ))
574
+
575
+ return ParseResult(
576
+ file_path=file_path,
577
+ classes=[o.to_class_dict() for o in objects],
578
+ imports=references,
579
+ method_calls=[],
580
+ string_literals=[],
581
+ )
582
+
583
+ # ------------------------------------------------------------------
584
+ # .properties SQL files
585
+ # ------------------------------------------------------------------
586
+
587
+ # Extra keywords that appear after FROM/JOIN in .properties but are not table names
588
+ _PROP_SKIP_NAMES = {
589
+ "NULL", "DUAL", "SET", "BY", "SELECT", "WHERE", "AND", "OR",
590
+ "PARAMETERS", "SCHEMA", "TABLE", "VIEW", "AS", "ON", "INNER",
591
+ "OUTER", "LEFT", "RIGHT", "CROSS", "FULL", "NATURAL",
592
+ "IF", "CASE", "WHEN", "THEN", "ELSE", "END", "NOT", "EXISTS",
593
+ "DISTINCT", "COUNT", "SUM", "MAX", "MIN", "AVG", "TOP",
594
+ "NOLOCK", "WITH", "PIVOT", "UNPIVOT", "OVER", "PARTITION",
595
+ }
596
+
597
+ def _parse_properties(self, file_path: Path, content: str) -> ParseResult:
598
+ """
599
+ Extract table names from embedded SQL queries in .properties files.
600
+
601
+ Handles these formats:
602
+ key=SELECT ... FROM #schema#.T_PICKWKAHDR ...
603
+ key=SELECT ... FROM ZMEDAPP.CC_SALESORDER ...
604
+ key=INSERT INTO {schema}.ORDER_HDR ...
605
+ key=UPDATE [dbo].[transferitem] SET ...
606
+ """
607
+ references: list[dict] = []
608
+ seen: set[str] = set()
609
+
610
+ # Strip line-continuation backslashes before scanning
611
+ normalised = content.replace("\\\n", " ")
612
+
613
+ for m in _RE_PROP_SQL_TABLE.finditer(normalised):
614
+ raw = m.group(1) or ""
615
+ name = _strip_quotes(raw).upper().strip()
616
+
617
+ # Skip placeholders, keywords and very short tokens
618
+ if not name or len(name) < 2:
619
+ continue
620
+ if name in self._PROP_SKIP_NAMES:
621
+ continue
622
+ # Skip if name looks like a placeholder (#xyz#, ${xyz})
623
+ if name.startswith("#") or name.startswith("$") or name.startswith("{"):
624
+ continue
625
+ # Skip pure numeric values
626
+ if name.isdigit():
627
+ continue
628
+
629
+ if name not in seen:
630
+ seen.add(name)
631
+ references.append({
632
+ "type": "READS_TABLE",
633
+ "target": name,
634
+ "line": normalised[:m.start()].count("\n") + 1,
635
+ })
636
+
637
+ return ParseResult(
638
+ file_path=file_path,
639
+ classes=[],
640
+ imports=references,
641
+ method_calls=[],
642
+ string_literals=[],
643
+ )
644
+
645
+ @staticmethod
646
+ def _empty(file_path: Path) -> ParseResult:
647
+ return ParseResult(file_path=file_path)
648
+
649
+
650
+ # ---------------------------------------------------------------------------
651
+ # Helpers
652
+ # ---------------------------------------------------------------------------
653
+
654
+ def _camel_to_snake(name: str) -> str:
655
+ """UserAccount → user_account."""
656
+ s = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', name)
657
+ s = re.sub(r'([a-z\d])([A-Z])', r'\1_\2', s)
658
+ return s.lower()