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.
- graph_dependency_analyzer-0.2.0.dist-info/METADATA +158 -0
- graph_dependency_analyzer-0.2.0.dist-info/RECORD +86 -0
- graph_dependency_analyzer-0.2.0.dist-info/WHEEL +5 -0
- graph_dependency_analyzer-0.2.0.dist-info/entry_points.txt +2 -0
- graph_dependency_analyzer-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/application/__init__.py +0 -0
- src/application/dtos/__init__.py +0 -0
- src/application/dtos/affected_component.py +59 -0
- src/application/dtos/batch_processing_result.py +17 -0
- src/application/dtos/impact_analysis_response.py +56 -0
- src/application/dtos/impact_query_request.py +38 -0
- src/application/dtos/index_batch_request.py +53 -0
- src/application/dtos/index_batch_response.py +72 -0
- src/application/dtos/index_result.py +20 -0
- src/application/dtos/repository_index_result.py +79 -0
- src/application/exceptions.py +41 -0
- src/application/services/__init__.py +0 -0
- src/application/services/file_scanner_service.py +258 -0
- src/application/services/progress_tracker.py +132 -0
- src/application/use_cases/__init__.py +0 -0
- src/application/use_cases/clear_all_data_use_case.py +180 -0
- src/application/use_cases/index_batch_use_case.py +153 -0
- src/application/use_cases/index_repository_use_case.py +683 -0
- src/application/use_cases/indexing_orchestrator.py +181 -0
- src/application/use_cases/list_repositories_use_case.py +84 -0
- src/cli.py +512 -0
- src/config/__init__.py +5 -0
- src/config/container.py +211 -0
- src/config/logging_config.py +123 -0
- src/config/settings.py +165 -0
- src/domain/__init__.py +0 -0
- src/domain/entities/__init__.py +20 -0
- src/domain/entities/ast_node.py +32 -0
- src/domain/entities/code_chunk.py +45 -0
- src/domain/entities/code_file.py +44 -0
- src/domain/entities/dependency_graph.py +110 -0
- src/domain/entities/repository.py +46 -0
- src/domain/exceptions.py +36 -0
- src/domain/repositories/__init__.py +22 -0
- src/domain/repositories/i_code_parser.py +50 -0
- src/domain/repositories/i_dependency_extractor.py +35 -0
- src/domain/repositories/i_embedding_provider.py +31 -0
- src/domain/repositories/i_graph_repository.py +78 -0
- src/domain/repositories/i_vector_repository.py +55 -0
- src/domain/services/__init__.py +8 -0
- src/domain/services/dependency_extractor.py +962 -0
- src/domain/services/semantic_chunk_builder.py +719 -0
- src/domain/value_objects/__init__.py +12 -0
- src/domain/value_objects/chunk_type.py +24 -0
- src/domain/value_objects/dependency_type.py +22 -0
- src/domain/value_objects/node_type.py +22 -0
- src/domain/value_objects/qualified_name.py +78 -0
- src/infrastructure/__init__.py +0 -0
- src/infrastructure/exceptions.py +41 -0
- src/infrastructure/external_apis/__init__.py +0 -0
- src/infrastructure/external_apis/ollama_embedding_provider.py +134 -0
- src/infrastructure/external_apis/openai_embedding_provider.py +231 -0
- src/infrastructure/parsing/__init__.py +20 -0
- src/infrastructure/parsing/config_file_parser.py +347 -0
- src/infrastructure/parsing/gradle_dependency_parser.py +159 -0
- src/infrastructure/parsing/groovy_parser.py +198 -0
- src/infrastructure/parsing/maven_dependency_parser.py +147 -0
- src/infrastructure/parsing/microfrontend_config_parser.py +242 -0
- src/infrastructure/parsing/npm_package_dependency_parser.py +255 -0
- src/infrastructure/parsing/python_parser.py +211 -0
- src/infrastructure/parsing/sql_schema_parser.py +658 -0
- src/infrastructure/parsing/tree_sitter_java_parser.py +434 -0
- src/infrastructure/parsing/tree_sitter_typescript_parser.py +600 -0
- src/infrastructure/persistence/__init__.py +0 -0
- src/infrastructure/persistence/graph_export_repository.py +443 -0
- src/infrastructure/persistence/neo4j_dependency_repository.py +657 -0
- src/infrastructure/persistence/qdrant_vector_repository.py +357 -0
- src/infrastructure/persistence/repository_relationship_repository.py +1312 -0
- src/presentation/__init__.py +0 -0
- src/presentation/api/__init__.py +0 -0
- src/presentation/api/rest/__init__.py +0 -0
- src/presentation/api/rest/exception_handlers.py +174 -0
- src/presentation/api/rest/main.py +80 -0
- src/presentation/api/rest/routers/__init__.py +5 -0
- src/presentation/api/rest/routers/admin.py +103 -0
- src/presentation/api/rest/routers/analysis.py +980 -0
- src/presentation/api/rest/routers/export.py +442 -0
- src/presentation/api/rest/routers/health.py +117 -0
- src/presentation/api/rest/routers/indexing.py +148 -0
- 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()
|