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,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Application Layer Exceptions (Task 9.3)
|
|
3
|
+
|
|
4
|
+
Exception hierarchy for the application layer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApplicationException(Exception):
|
|
9
|
+
"""Base exception for application layer errors."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, details: dict | None = None):
|
|
12
|
+
"""
|
|
13
|
+
Initialize application exception.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message: Human-readable error message
|
|
17
|
+
details: Optional dict with additional context
|
|
18
|
+
"""
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.message = message
|
|
21
|
+
self.details = details or {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IndexingError(ApplicationException):
|
|
25
|
+
"""Error during indexing operations."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RepositoryNotFoundError(ApplicationException):
|
|
30
|
+
"""Repository not found in filesystem."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class NoValidRepositoriesError(ApplicationException):
|
|
35
|
+
"""No valid repositories found in the provided list."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class EmptyRepositoryListError(ApplicationException):
|
|
40
|
+
"""Repository list is empty."""
|
|
41
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FileScannerService Implementation (Task 7.1)
|
|
3
|
+
|
|
4
|
+
Scans repository directory for Java files, filtering out build artifacts
|
|
5
|
+
and large/binary files.
|
|
6
|
+
"""
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
logger = structlog.get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileScannerService:
|
|
14
|
+
"""
|
|
15
|
+
Application service for scanning repository for valid Java and TypeScript/JavaScript files.
|
|
16
|
+
|
|
17
|
+
Filters out:
|
|
18
|
+
- Build directories (target/, build/, .git/, .idea/, node_modules/, dist/)
|
|
19
|
+
- Large files (>1MB)
|
|
20
|
+
- Binary files
|
|
21
|
+
- Non-source files
|
|
22
|
+
|
|
23
|
+
Requirements: 3.3, 3.4, 3.5, 8.1, 12.1 (Task 6.1)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Directories to skip during scanning
|
|
27
|
+
SKIP_DIRECTORIES = {
|
|
28
|
+
"target", "build", ".git", ".idea", "node_modules", ".gradle", "dist",
|
|
29
|
+
# test / load-test dirs
|
|
30
|
+
"k6-tests", "__tests__", "e2e", "cypress", "playwright",
|
|
31
|
+
# frontend build outputs
|
|
32
|
+
".next", ".nuxt", ".svelte-kit", "coverage", "storybook-static",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# File name suffixes to skip (test files)
|
|
36
|
+
SKIP_FILE_SUFFIXES = {
|
|
37
|
+
".test.ts", ".test.tsx", ".test.js", ".test.jsx",
|
|
38
|
+
".spec.ts", ".spec.tsx", ".spec.js", ".spec.jsx",
|
|
39
|
+
".stories.ts", ".stories.tsx", ".stories.js",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# TypeScript/JavaScript file extensions
|
|
43
|
+
TYPESCRIPT_EXTENSIONS = {".ts", ".tsx"}
|
|
44
|
+
JAVASCRIPT_EXTENSIONS = {".js", ".jsx"}
|
|
45
|
+
|
|
46
|
+
# SQL / DB schema file extensions
|
|
47
|
+
SQL_EXTENSIONS = {".sql", ".SQL", ".pks", ".pkb", ".prc", ".fnc",
|
|
48
|
+
".trg", ".vw", ".tbl", ".seq", ".idx", ".typ"}
|
|
49
|
+
|
|
50
|
+
# Python file extensions
|
|
51
|
+
PYTHON_EXTENSIONS = {".py", ".pyw"}
|
|
52
|
+
|
|
53
|
+
# Groovy / Jenkinsfile extensions
|
|
54
|
+
GROOVY_EXTENSIONS = {".groovy"}
|
|
55
|
+
|
|
56
|
+
# Maximum file size (1MB)
|
|
57
|
+
MAX_FILE_SIZE = 1024 * 1024 # 1MB in bytes
|
|
58
|
+
|
|
59
|
+
def scan(self, repository_path: Path) -> dict[str, list[Path]]:
|
|
60
|
+
"""
|
|
61
|
+
Scan repository for valid Java, TypeScript, and JavaScript files.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
repository_path: Absolute path to repository root
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Dict with keys "java", "typescript", "javascript" containing lists of file paths
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If path does not exist or is not a directory
|
|
71
|
+
"""
|
|
72
|
+
# Convert to Path if string
|
|
73
|
+
if isinstance(repository_path, str):
|
|
74
|
+
repository_path = Path(repository_path)
|
|
75
|
+
|
|
76
|
+
# Validate path exists
|
|
77
|
+
if not repository_path.exists():
|
|
78
|
+
raise ValueError(f"Path does not exist: {repository_path}")
|
|
79
|
+
|
|
80
|
+
# Validate path is a directory
|
|
81
|
+
if not repository_path.is_dir():
|
|
82
|
+
raise ValueError(f"Path is not a directory: {repository_path}")
|
|
83
|
+
|
|
84
|
+
# Resolve to absolute path
|
|
85
|
+
repository_path = repository_path.resolve()
|
|
86
|
+
|
|
87
|
+
java_files = []
|
|
88
|
+
typescript_files = []
|
|
89
|
+
javascript_files = []
|
|
90
|
+
sql_files = []
|
|
91
|
+
python_files = []
|
|
92
|
+
groovy_files = []
|
|
93
|
+
other_extensions = {}
|
|
94
|
+
large_files_count = 0
|
|
95
|
+
binary_files_count = 0
|
|
96
|
+
|
|
97
|
+
# Walk directory tree
|
|
98
|
+
for file_path in repository_path.rglob("*"):
|
|
99
|
+
# Skip if not a file
|
|
100
|
+
if not file_path.is_file():
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
# Skip if in excluded directory
|
|
104
|
+
if self._is_in_skip_directory(file_path, repository_path):
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Skip test/spec files by name suffix
|
|
108
|
+
if self._is_test_file(file_path):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# Count all file extensions
|
|
112
|
+
ext = file_path.suffix
|
|
113
|
+
fname_lower = file_path.name.lower()
|
|
114
|
+
if ext:
|
|
115
|
+
other_extensions[ext] = other_extensions.get(ext, 0) + 1
|
|
116
|
+
|
|
117
|
+
# SQL query properties files (e.g. webServiceSql.properties)
|
|
118
|
+
is_sql_props = (
|
|
119
|
+
ext == ".properties"
|
|
120
|
+
and (fname_lower.endswith("sql.properties") or fname_lower.endswith("sql.props"))
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Skip files without supported extensions
|
|
124
|
+
all_supported = {".java"} | self.TYPESCRIPT_EXTENSIONS | self.JAVASCRIPT_EXTENSIONS | self.SQL_EXTENSIONS | self.PYTHON_EXTENSIONS | self.GROOVY_EXTENSIONS
|
|
125
|
+
if ext not in all_supported and not is_sql_props:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Check file size
|
|
129
|
+
try:
|
|
130
|
+
file_size = file_path.stat().st_size
|
|
131
|
+
if file_size > self.MAX_FILE_SIZE:
|
|
132
|
+
large_files_count += 1
|
|
133
|
+
logger.warning(
|
|
134
|
+
"file_too_large",
|
|
135
|
+
file_path=str(file_path),
|
|
136
|
+
size_mb=file_size / (1024 * 1024)
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
except OSError as e:
|
|
140
|
+
logger.warning(
|
|
141
|
+
"file_stat_error",
|
|
142
|
+
file_path=str(file_path),
|
|
143
|
+
error=str(e)
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Check if file is text (not binary)
|
|
148
|
+
if not self._is_text_file(file_path):
|
|
149
|
+
binary_files_count += 1
|
|
150
|
+
logger.warning(
|
|
151
|
+
"binary_file_skipped",
|
|
152
|
+
file_path=str(file_path)
|
|
153
|
+
)
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Categorize by extension
|
|
157
|
+
if ext == ".java":
|
|
158
|
+
java_files.append(file_path)
|
|
159
|
+
elif ext in self.TYPESCRIPT_EXTENSIONS:
|
|
160
|
+
typescript_files.append(file_path)
|
|
161
|
+
elif ext in self.JAVASCRIPT_EXTENSIONS:
|
|
162
|
+
javascript_files.append(file_path)
|
|
163
|
+
elif ext in self.SQL_EXTENSIONS or is_sql_props:
|
|
164
|
+
sql_files.append(file_path)
|
|
165
|
+
elif ext in self.PYTHON_EXTENSIONS:
|
|
166
|
+
python_files.append(file_path)
|
|
167
|
+
elif ext in self.GROOVY_EXTENSIONS:
|
|
168
|
+
groovy_files.append(file_path)
|
|
169
|
+
elif fname_lower in ('jenkinsfile',) or fname_lower.startswith('jenkinsfile'):
|
|
170
|
+
groovy_files.append(file_path)
|
|
171
|
+
|
|
172
|
+
# Log summary
|
|
173
|
+
logger.info(
|
|
174
|
+
"repository_scan_complete",
|
|
175
|
+
repository=str(repository_path),
|
|
176
|
+
java_files_found=len(java_files),
|
|
177
|
+
typescript_files_found=len(typescript_files),
|
|
178
|
+
javascript_files_found=len(javascript_files),
|
|
179
|
+
sql_files_found=len(sql_files),
|
|
180
|
+
python_files_found=len(python_files),
|
|
181
|
+
groovy_files_found=len(groovy_files),
|
|
182
|
+
large_files_filtered=large_files_count,
|
|
183
|
+
binary_files_filtered=binary_files_count,
|
|
184
|
+
extensions_found=dict(other_extensions)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"java": java_files,
|
|
189
|
+
"typescript": typescript_files,
|
|
190
|
+
"javascript": javascript_files,
|
|
191
|
+
"sql": sql_files,
|
|
192
|
+
"python": python_files,
|
|
193
|
+
"groovy": groovy_files,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _is_in_skip_directory(self, file_path: Path, repository_root: Path) -> bool:
|
|
197
|
+
"""
|
|
198
|
+
Check if file is inside a directory that should be skipped.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
file_path: Absolute path to file
|
|
202
|
+
repository_root: Repository root path
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if file is in a skip directory
|
|
206
|
+
"""
|
|
207
|
+
# Get relative path from repository root
|
|
208
|
+
try:
|
|
209
|
+
relative_path = file_path.relative_to(repository_root)
|
|
210
|
+
except ValueError:
|
|
211
|
+
# File is not relative to repository root
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
# Check all parts of the path
|
|
215
|
+
for part in relative_path.parts:
|
|
216
|
+
if part in self.SKIP_DIRECTORIES:
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
def _is_test_file(self, file_path: Path) -> bool:
|
|
222
|
+
"""Check if file is a test/spec file by name pattern."""
|
|
223
|
+
name = file_path.name
|
|
224
|
+
return any(name.endswith(suffix) for suffix in self.SKIP_FILE_SUFFIXES)
|
|
225
|
+
|
|
226
|
+
def _is_text_file(self, file_path: Path) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
Check if file is a text file (not binary).
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
file_path: Path to file
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if file appears to be text
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
# Read first 8192 bytes
|
|
238
|
+
with open(file_path, 'rb') as f:
|
|
239
|
+
chunk = f.read(8192)
|
|
240
|
+
|
|
241
|
+
# Check for null bytes (indicator of binary file)
|
|
242
|
+
if b'\x00' in chunk:
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
# Try to decode as UTF-8
|
|
246
|
+
try:
|
|
247
|
+
chunk.decode('utf-8')
|
|
248
|
+
return True
|
|
249
|
+
except UnicodeDecodeError:
|
|
250
|
+
# Try other common encodings
|
|
251
|
+
try:
|
|
252
|
+
chunk.decode('latin-1')
|
|
253
|
+
return True
|
|
254
|
+
except UnicodeDecodeError:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
except Exception:
|
|
258
|
+
return False
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ProgressTracker Implementation (Task 7.2)
|
|
3
|
+
|
|
4
|
+
Tracks progress of repository indexing through 8 stages with percentage mapping.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Callable, TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProgressUpdate(TypedDict):
|
|
10
|
+
"""Progress update data structure"""
|
|
11
|
+
repository_name: str
|
|
12
|
+
stage: str
|
|
13
|
+
message: str
|
|
14
|
+
percentage: int
|
|
15
|
+
status: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProgressTracker:
|
|
19
|
+
"""
|
|
20
|
+
Application service for tracking indexing progress.
|
|
21
|
+
|
|
22
|
+
Maps 8 stages to percentage ranges:
|
|
23
|
+
- validation: 0%
|
|
24
|
+
- scan: 0-10% (midpoint: 5%)
|
|
25
|
+
- ast: 10-40% (midpoint: 25%)
|
|
26
|
+
- deps: 40-60% (midpoint: 50%)
|
|
27
|
+
- chunking: 60-70% (midpoint: 65%)
|
|
28
|
+
- embeddings: 70-85% (midpoint: 77%)
|
|
29
|
+
- qdrant: 85-95% (midpoint: 90%)
|
|
30
|
+
- neo4j: 95-100% (midpoint: 97%)
|
|
31
|
+
|
|
32
|
+
Requirements: 2.7, 3.15
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
# Stage to percentage mapping (midpoint of ranges)
|
|
36
|
+
STAGE_PERCENTAGES = {
|
|
37
|
+
"validation": 0,
|
|
38
|
+
"scan": 5,
|
|
39
|
+
"ast": 25,
|
|
40
|
+
"deps": 50,
|
|
41
|
+
"chunking": 65,
|
|
42
|
+
"embeddings": 77,
|
|
43
|
+
"qdrant": 90,
|
|
44
|
+
"neo4j": 97,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
repository_name: str,
|
|
50
|
+
callback: Callable[[ProgressUpdate], None] | None = None
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
Initialize progress tracker.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
repository_name: Name of repository being indexed
|
|
57
|
+
callback: Optional callback to invoke on progress updates
|
|
58
|
+
"""
|
|
59
|
+
self.repository_name = repository_name
|
|
60
|
+
self.callback = callback
|
|
61
|
+
self.current_percentage = 0
|
|
62
|
+
self.current_stage = ""
|
|
63
|
+
self.current_status = "in_progress"
|
|
64
|
+
|
|
65
|
+
def update(self, stage: str, message: str) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Update progress to a specific stage.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
stage: Stage name (validation, scan, ast, deps, chunking, embeddings, qdrant, neo4j)
|
|
71
|
+
message: Progress message
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If stage is not recognized
|
|
75
|
+
"""
|
|
76
|
+
if stage not in self.STAGE_PERCENTAGES:
|
|
77
|
+
raise ValueError(f"Unknown stage: {stage}")
|
|
78
|
+
|
|
79
|
+
self.current_stage = stage
|
|
80
|
+
self.current_percentage = self.STAGE_PERCENTAGES[stage]
|
|
81
|
+
self.current_status = "in_progress"
|
|
82
|
+
|
|
83
|
+
if self.callback:
|
|
84
|
+
progress_data: ProgressUpdate = {
|
|
85
|
+
"repository_name": self.repository_name,
|
|
86
|
+
"stage": stage,
|
|
87
|
+
"message": message,
|
|
88
|
+
"percentage": self.current_percentage,
|
|
89
|
+
"status": self.current_status,
|
|
90
|
+
}
|
|
91
|
+
self.callback(progress_data)
|
|
92
|
+
|
|
93
|
+
def complete(self, message: str = "Completed") -> None:
|
|
94
|
+
"""
|
|
95
|
+
Mark indexing as complete (100%).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
message: Completion message
|
|
99
|
+
"""
|
|
100
|
+
self.current_percentage = 100
|
|
101
|
+
self.current_status = "completed"
|
|
102
|
+
|
|
103
|
+
if self.callback:
|
|
104
|
+
progress_data: ProgressUpdate = {
|
|
105
|
+
"repository_name": self.repository_name,
|
|
106
|
+
"stage": "complete",
|
|
107
|
+
"message": message,
|
|
108
|
+
"percentage": 100,
|
|
109
|
+
"status": "completed",
|
|
110
|
+
}
|
|
111
|
+
self.callback(progress_data)
|
|
112
|
+
|
|
113
|
+
def fail(self, message: str) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Mark indexing as failed.
|
|
116
|
+
|
|
117
|
+
Keeps current percentage but sets status to error.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
message: Error message
|
|
121
|
+
"""
|
|
122
|
+
self.current_status = "error"
|
|
123
|
+
|
|
124
|
+
if self.callback:
|
|
125
|
+
progress_data: ProgressUpdate = {
|
|
126
|
+
"repository_name": self.repository_name,
|
|
127
|
+
"stage": self.current_stage,
|
|
128
|
+
"message": message,
|
|
129
|
+
"percentage": self.current_percentage,
|
|
130
|
+
"status": "error",
|
|
131
|
+
}
|
|
132
|
+
self.callback(progress_data)
|
|
File without changes
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Use case for clearing all data from Qdrant and Neo4j."""
|
|
2
|
+
import structlog
|
|
3
|
+
|
|
4
|
+
from src.infrastructure.persistence.neo4j_dependency_repository import Neo4jDependencyGraphRepository
|
|
5
|
+
from qdrant_client import QdrantClient
|
|
6
|
+
|
|
7
|
+
logger = structlog.get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClearAllDataUseCase:
|
|
11
|
+
"""
|
|
12
|
+
Use case for clearing all indexed data.
|
|
13
|
+
|
|
14
|
+
Deletes:
|
|
15
|
+
- All collections in Qdrant
|
|
16
|
+
- All nodes and relationships in Neo4j
|
|
17
|
+
|
|
18
|
+
⚠️ DESTRUCTIVE OPERATION - Use with caution!
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
graph_repository: Neo4jDependencyGraphRepository,
|
|
24
|
+
qdrant_client: QdrantClient,
|
|
25
|
+
collection_name: str
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize use case.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
graph_repository: Neo4j dependency graph repository
|
|
32
|
+
qdrant_client: Qdrant client
|
|
33
|
+
collection_name: Name of Qdrant collection to delete
|
|
34
|
+
"""
|
|
35
|
+
self.graph_repository = graph_repository
|
|
36
|
+
self.qdrant_client = qdrant_client
|
|
37
|
+
self.collection_name = collection_name
|
|
38
|
+
|
|
39
|
+
async def execute(self) -> dict:
|
|
40
|
+
"""
|
|
41
|
+
Execute clear all operation.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Dict with operation results
|
|
45
|
+
"""
|
|
46
|
+
logger.warning("clear_all_data_starting", operation="DESTRUCTIVE")
|
|
47
|
+
|
|
48
|
+
results = {
|
|
49
|
+
"neo4j": {"success": False, "nodes_deleted": 0, "relationships_deleted": 0},
|
|
50
|
+
"qdrant": {"success": False, "collection_deleted": False}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# 1. Clear Neo4j
|
|
54
|
+
try:
|
|
55
|
+
neo4j_result = await self._clear_neo4j()
|
|
56
|
+
results["neo4j"] = neo4j_result
|
|
57
|
+
logger.info(
|
|
58
|
+
"neo4j_cleared",
|
|
59
|
+
nodes_deleted=neo4j_result["nodes_deleted"],
|
|
60
|
+
relationships_deleted=neo4j_result["relationships_deleted"]
|
|
61
|
+
)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.error(
|
|
64
|
+
"neo4j_clear_failed",
|
|
65
|
+
error=str(e),
|
|
66
|
+
error_type=type(e).__name__
|
|
67
|
+
)
|
|
68
|
+
results["neo4j"]["error"] = str(e)
|
|
69
|
+
|
|
70
|
+
# 2. Clear Qdrant
|
|
71
|
+
try:
|
|
72
|
+
qdrant_result = await self._clear_qdrant()
|
|
73
|
+
results["qdrant"] = qdrant_result
|
|
74
|
+
logger.info(
|
|
75
|
+
"qdrant_cleared",
|
|
76
|
+
collection_deleted=qdrant_result["collection_deleted"]
|
|
77
|
+
)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(
|
|
80
|
+
"qdrant_clear_failed",
|
|
81
|
+
error=str(e),
|
|
82
|
+
error_type=type(e).__name__
|
|
83
|
+
)
|
|
84
|
+
results["qdrant"]["error"] = str(e)
|
|
85
|
+
|
|
86
|
+
logger.warning("clear_all_data_completed", results=results)
|
|
87
|
+
|
|
88
|
+
return results
|
|
89
|
+
|
|
90
|
+
async def _clear_neo4j(self) -> dict:
|
|
91
|
+
"""
|
|
92
|
+
Clear all nodes and relationships from Neo4j.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Dict with deletion metrics
|
|
96
|
+
"""
|
|
97
|
+
async with self.graph_repository.driver.session() as session:
|
|
98
|
+
# Get counts before deletion
|
|
99
|
+
count_result = await session.run("""
|
|
100
|
+
MATCH (n)
|
|
101
|
+
OPTIONAL MATCH (n)-[r]->()
|
|
102
|
+
RETURN count(DISTINCT n) as nodes, count(r) as rels
|
|
103
|
+
""")
|
|
104
|
+
count_record = await count_result.single()
|
|
105
|
+
nodes_before = count_record["nodes"]
|
|
106
|
+
rels_before = count_record["rels"]
|
|
107
|
+
|
|
108
|
+
# Delete in batches of 10k to avoid Neo4j heap overflow
|
|
109
|
+
# (single DETACH DELETE on large graphs silently fails with OOM)
|
|
110
|
+
total_deleted = 0
|
|
111
|
+
while True:
|
|
112
|
+
result = await session.run("""
|
|
113
|
+
MATCH (n)
|
|
114
|
+
WITH n LIMIT 10000
|
|
115
|
+
DETACH DELETE n
|
|
116
|
+
RETURN count(*) AS deleted
|
|
117
|
+
""")
|
|
118
|
+
row = await result.single()
|
|
119
|
+
batch = row["deleted"] if row else 0
|
|
120
|
+
total_deleted += batch
|
|
121
|
+
if batch == 0:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
logger.debug(
|
|
125
|
+
"neo4j_deletion_executed",
|
|
126
|
+
nodes_deleted=total_deleted,
|
|
127
|
+
relationships_deleted=rels_before
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"success": True,
|
|
132
|
+
"nodes_deleted": total_deleted,
|
|
133
|
+
"relationships_deleted": rels_before
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async def _clear_qdrant(self) -> dict:
|
|
137
|
+
"""
|
|
138
|
+
Clear Qdrant collection.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Dict with deletion result
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
# Check if collection exists
|
|
145
|
+
collections = self.qdrant_client.get_collections().collections
|
|
146
|
+
collection_exists = any(c.name == self.collection_name for c in collections)
|
|
147
|
+
|
|
148
|
+
if collection_exists:
|
|
149
|
+
# Delete collection
|
|
150
|
+
self.qdrant_client.delete_collection(collection_name=self.collection_name)
|
|
151
|
+
|
|
152
|
+
logger.debug(
|
|
153
|
+
"qdrant_collection_deleted",
|
|
154
|
+
collection=self.collection_name
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"success": True,
|
|
159
|
+
"collection_deleted": True,
|
|
160
|
+
"collection_name": self.collection_name
|
|
161
|
+
}
|
|
162
|
+
else:
|
|
163
|
+
logger.debug(
|
|
164
|
+
"qdrant_collection_not_found",
|
|
165
|
+
collection=self.collection_name
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"success": True,
|
|
170
|
+
"collection_deleted": False,
|
|
171
|
+
"message": f"Collection '{self.collection_name}' does not exist"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.error(
|
|
176
|
+
"qdrant_deletion_failed",
|
|
177
|
+
error=str(e),
|
|
178
|
+
collection=self.collection_name
|
|
179
|
+
)
|
|
180
|
+
raise
|