codegraphcontext-rust 0.5.5__cp310-cp310-win_amd64.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.
- codegraphcontext/__init__.py +2 -0
- codegraphcontext/__main__.py +5 -0
- codegraphcontext/_cgc_rust.cp310-win_amd64.pyd +0 -0
- codegraphcontext/cli/__init__.py +1 -0
- codegraphcontext/cli/cli_helpers.py +774 -0
- codegraphcontext/cli/config_manager.py +1051 -0
- codegraphcontext/cli/main.py +2386 -0
- codegraphcontext/cli/registry_commands.py +404 -0
- codegraphcontext/cli/setup_macos.py +93 -0
- codegraphcontext/cli/setup_wizard.py +991 -0
- codegraphcontext/cli/visualizer.py +50 -0
- codegraphcontext/core/__init__.py +165 -0
- codegraphcontext/core/bundle_registry.py +181 -0
- codegraphcontext/core/cgc_bundle.py +858 -0
- codegraphcontext/core/cgcignore.py +118 -0
- codegraphcontext/core/database.py +273 -0
- codegraphcontext/core/database_falkordb.py +480 -0
- codegraphcontext/core/database_falkordb_remote.py +199 -0
- codegraphcontext/core/database_kuzu.py +636 -0
- codegraphcontext/core/falkor_worker.py +132 -0
- codegraphcontext/core/jobs.py +132 -0
- codegraphcontext/core/watcher.py +260 -0
- codegraphcontext/prompts.py +125 -0
- codegraphcontext/server.py +471 -0
- codegraphcontext/tool_definitions.py +220 -0
- codegraphcontext/tools/__init__.py +0 -0
- codegraphcontext/tools/advanced_language_query_tool.py +103 -0
- codegraphcontext/tools/code_finder.py +1160 -0
- codegraphcontext/tools/graph_builder.py +337 -0
- codegraphcontext/tools/handlers/analysis_handlers.py +114 -0
- codegraphcontext/tools/handlers/indexing_handlers.py +116 -0
- codegraphcontext/tools/handlers/management_handlers.py +339 -0
- codegraphcontext/tools/handlers/query_handlers.py +83 -0
- codegraphcontext/tools/handlers/watcher_handlers.py +83 -0
- codegraphcontext/tools/indexing/__init__.py +1 -0
- codegraphcontext/tools/indexing/constants.py +25 -0
- codegraphcontext/tools/indexing/discovery.py +64 -0
- codegraphcontext/tools/indexing/engine.py +109 -0
- codegraphcontext/tools/indexing/persistence/__init__.py +3 -0
- codegraphcontext/tools/indexing/persistence/parquet_loader.py +643 -0
- codegraphcontext/tools/indexing/persistence/writer.py +1114 -0
- codegraphcontext/tools/indexing/pipeline.py +171 -0
- codegraphcontext/tools/indexing/pre_scan.py +105 -0
- codegraphcontext/tools/indexing/resolution/__init__.py +8 -0
- codegraphcontext/tools/indexing/resolution/calls.py +253 -0
- codegraphcontext/tools/indexing/resolution/inheritance.py +91 -0
- codegraphcontext/tools/indexing/sanitize.py +41 -0
- codegraphcontext/tools/indexing/schema.py +79 -0
- codegraphcontext/tools/indexing/schema_contract.py +44 -0
- codegraphcontext/tools/indexing/scip_pipeline.py +140 -0
- codegraphcontext/tools/languages/c.py +563 -0
- codegraphcontext/tools/languages/cpp.py +616 -0
- codegraphcontext/tools/languages/csharp.py +551 -0
- codegraphcontext/tools/languages/dart.py +378 -0
- codegraphcontext/tools/languages/elixir.py +460 -0
- codegraphcontext/tools/languages/go.py +499 -0
- codegraphcontext/tools/languages/haskell.py +426 -0
- codegraphcontext/tools/languages/java.py +500 -0
- codegraphcontext/tools/languages/javascript.py +589 -0
- codegraphcontext/tools/languages/kotlin.py +640 -0
- codegraphcontext/tools/languages/perl.py +260 -0
- codegraphcontext/tools/languages/php.py +519 -0
- codegraphcontext/tools/languages/python.py +576 -0
- codegraphcontext/tools/languages/ruby.py +537 -0
- codegraphcontext/tools/languages/rust.py +295 -0
- codegraphcontext/tools/languages/scala.py +519 -0
- codegraphcontext/tools/languages/swift.py +491 -0
- codegraphcontext/tools/languages/typescript.py +575 -0
- codegraphcontext/tools/languages/typescriptjsx.py +151 -0
- codegraphcontext/tools/package_resolver.py +472 -0
- codegraphcontext/tools/query_tool_languages/c_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/cpp_toolkit.py +87 -0
- codegraphcontext/tools/query_tool_languages/csharp_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/dart_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/go_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/haskell_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/java_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/javascript_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/perl_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/python_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/ruby_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/rust_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/scala_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/swift_toolkit.py +5 -0
- codegraphcontext/tools/query_tool_languages/typescript_toolkit.py +5 -0
- codegraphcontext/tools/scip_indexer.py +467 -0
- codegraphcontext/tools/scip_pb2.py +2455 -0
- codegraphcontext/tools/system.py +133 -0
- codegraphcontext/tools/tree_sitter_parser.py +103 -0
- codegraphcontext/utils/debug_log.py +90 -0
- codegraphcontext/utils/path_ignore.py +55 -0
- codegraphcontext/utils/repo_path.py +27 -0
- codegraphcontext/utils/tree_sitter_manager.py +285 -0
- codegraphcontext/utils/visualize_graph.py +0 -0
- codegraphcontext/viz/server.py +282 -0
- codegraphcontext_rust-0.5.5.dist-info/METADATA +537 -0
- codegraphcontext_rust-0.5.5.dist-info/RECORD +101 -0
- codegraphcontext_rust-0.5.5.dist-info/WHEEL +4 -0
- codegraphcontext_rust-0.5.5.dist-info/entry_points.txt +3 -0
- codegraphcontext_rust-0.5.5.dist-info/licenses/LICENSE +21 -0
- codegraphcontext_rust-0.5.5.dist-info/sboms/cgc-python.cyclonedx.json +2333 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
# src/codegraphcontext/core/cgc_bundle.py
|
|
2
|
+
"""
|
|
3
|
+
This module handles the creation and loading of .cgc (CodeGraphContext Bundle) files.
|
|
4
|
+
|
|
5
|
+
A .cgc file is a portable, pre-indexed graph snapshot that can be distributed and loaded
|
|
6
|
+
instantly without re-indexing. This enables:
|
|
7
|
+
- Pre-indexing famous repositories once
|
|
8
|
+
- Distributing graph knowledge as artifacts
|
|
9
|
+
- Instant context loading for LLMs
|
|
10
|
+
- Version-controlled code knowledge
|
|
11
|
+
|
|
12
|
+
Bundle Structure:
|
|
13
|
+
.cgc (ZIP archive)
|
|
14
|
+
├── metadata.json # Repository and indexing metadata
|
|
15
|
+
├── schema.json # Graph schema definition
|
|
16
|
+
├── nodes.jsonl # All nodes (one JSON object per line)
|
|
17
|
+
├── edges.jsonl # All relationships (one JSON object per line)
|
|
18
|
+
├── stats.json # Graph statistics
|
|
19
|
+
└── README.md # Human-readable description
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import zipfile
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Dict, List, Optional, Any, Tuple
|
|
27
|
+
from datetime import datetime, date
|
|
28
|
+
import subprocess
|
|
29
|
+
|
|
30
|
+
from codegraphcontext.utils.debug_log import debug_log, info_logger, error_logger, warning_logger
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _BundleEncoder(json.JSONEncoder):
|
|
34
|
+
"""Handles Neo4j DateTime and other non-standard types for bundle serialization."""
|
|
35
|
+
def default(self, obj):
|
|
36
|
+
if isinstance(obj, (datetime, date)):
|
|
37
|
+
return obj.isoformat()
|
|
38
|
+
if hasattr(obj, 'isoformat'):
|
|
39
|
+
return obj.isoformat()
|
|
40
|
+
if hasattr(obj, 'iso_format'):
|
|
41
|
+
return obj.iso_format()
|
|
42
|
+
if isinstance(obj, Path):
|
|
43
|
+
return str(obj)
|
|
44
|
+
if isinstance(obj, set):
|
|
45
|
+
return list(obj)
|
|
46
|
+
if isinstance(obj, bytes):
|
|
47
|
+
return obj.decode('utf-8', errors='replace')
|
|
48
|
+
return super().default(obj)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CGCBundle:
|
|
52
|
+
"""Handles creation and loading of .cgc bundle files."""
|
|
53
|
+
|
|
54
|
+
VERSION = "0.1.0" # CGC bundle format version
|
|
55
|
+
|
|
56
|
+
def __init__(self, db_manager):
|
|
57
|
+
"""
|
|
58
|
+
Initialize the CGC Bundle handler.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
db_manager: DatabaseManager instance for graph queries
|
|
62
|
+
"""
|
|
63
|
+
self.db_manager = db_manager
|
|
64
|
+
|
|
65
|
+
def _get_id_function(self) -> str:
|
|
66
|
+
"""
|
|
67
|
+
Get the appropriate ID function based on the database backend.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
str: 'elementId' for Neo4j, 'id' for FalkorDB
|
|
71
|
+
"""
|
|
72
|
+
# Check if we're using Neo4j or FalkorDB
|
|
73
|
+
backend = self.db_manager.get_backend_type()
|
|
74
|
+
if backend == 'neo4j':
|
|
75
|
+
return 'elementId'
|
|
76
|
+
else: # FalkorDB or other backends
|
|
77
|
+
return 'id'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def export_to_bundle(
|
|
81
|
+
self,
|
|
82
|
+
output_path: Path,
|
|
83
|
+
repo_path: Optional[Path] = None,
|
|
84
|
+
include_stats: bool = True
|
|
85
|
+
) -> Tuple[bool, str]:
|
|
86
|
+
"""
|
|
87
|
+
Export the current graph (or a specific repository) to a .cgc bundle.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
output_path: Path where the .cgc file should be saved
|
|
91
|
+
repo_path: Optional specific repository path to export (None = export all)
|
|
92
|
+
include_stats: Whether to include detailed statistics
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Tuple[bool, str]: (success, message)
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
info_logger(f"Starting export to {output_path}")
|
|
99
|
+
|
|
100
|
+
# Ensure output path has .cgc extension
|
|
101
|
+
if not str(output_path).endswith('.cgc'):
|
|
102
|
+
output_path = Path(str(output_path) + '.cgc')
|
|
103
|
+
|
|
104
|
+
# Create temporary directory for bundle contents
|
|
105
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
106
|
+
temp_path = Path(temp_dir)
|
|
107
|
+
|
|
108
|
+
# Step 1: Extract metadata
|
|
109
|
+
info_logger("Extracting metadata...")
|
|
110
|
+
metadata = self._extract_metadata(repo_path)
|
|
111
|
+
with open(temp_path / "metadata.json", 'w') as f:
|
|
112
|
+
json.dump(metadata, f, indent=2, cls=_BundleEncoder)
|
|
113
|
+
|
|
114
|
+
# Step 2: Extract schema
|
|
115
|
+
info_logger("Extracting schema...")
|
|
116
|
+
schema = self._extract_schema()
|
|
117
|
+
with open(temp_path / "schema.json", 'w') as f:
|
|
118
|
+
json.dump(schema, f, indent=2, cls=_BundleEncoder)
|
|
119
|
+
|
|
120
|
+
# Step 3: Extract nodes
|
|
121
|
+
info_logger("Extracting nodes...")
|
|
122
|
+
node_count = self._extract_nodes(temp_path / "nodes.jsonl", repo_path)
|
|
123
|
+
|
|
124
|
+
# Step 4: Extract edges
|
|
125
|
+
info_logger("Extracting edges...")
|
|
126
|
+
edge_count = self._extract_edges(temp_path / "edges.jsonl", repo_path)
|
|
127
|
+
|
|
128
|
+
# Step 5: Generate statistics
|
|
129
|
+
if include_stats:
|
|
130
|
+
info_logger("Generating statistics...")
|
|
131
|
+
stats = self._generate_stats(repo_path, node_count, edge_count)
|
|
132
|
+
with open(temp_path / "stats.json", 'w') as f:
|
|
133
|
+
json.dump(stats, f, indent=2, cls=_BundleEncoder)
|
|
134
|
+
|
|
135
|
+
# Step 6: Create README
|
|
136
|
+
self._create_readme(temp_path / "README.md", metadata, stats if include_stats else None)
|
|
137
|
+
|
|
138
|
+
# Step 7: Create ZIP archive
|
|
139
|
+
info_logger("Creating bundle archive...")
|
|
140
|
+
self._create_zip(temp_path, output_path)
|
|
141
|
+
|
|
142
|
+
success_msg = f"✅ Successfully exported to {output_path}\n"
|
|
143
|
+
success_msg += f" Nodes: {node_count:,} | Edges: {edge_count:,}"
|
|
144
|
+
info_logger(success_msg)
|
|
145
|
+
return True, success_msg
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
import traceback
|
|
149
|
+
error_msg = f"Failed to export bundle: {str(e)}"
|
|
150
|
+
error_logger(error_msg)
|
|
151
|
+
# Print full traceback for debugging
|
|
152
|
+
traceback.print_exc()
|
|
153
|
+
return False, error_msg
|
|
154
|
+
|
|
155
|
+
def import_from_bundle(
|
|
156
|
+
self,
|
|
157
|
+
bundle_path: Path,
|
|
158
|
+
clear_existing: bool = False,
|
|
159
|
+
readonly: bool = False
|
|
160
|
+
) -> Tuple[bool, str]:
|
|
161
|
+
"""
|
|
162
|
+
Import a .cgc bundle into the current database.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
bundle_path: Path to the .cgc file
|
|
166
|
+
clear_existing: Whether to clear existing graph data first
|
|
167
|
+
readonly: If True, mount as read-only (future feature)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Tuple[bool, str]: (success, message)
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
info_logger(f"Starting import from {bundle_path}")
|
|
174
|
+
|
|
175
|
+
if not bundle_path.exists():
|
|
176
|
+
return False, f"Bundle file not found: {bundle_path}"
|
|
177
|
+
|
|
178
|
+
# Extract bundle to temporary directory
|
|
179
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
180
|
+
temp_path = Path(temp_dir)
|
|
181
|
+
|
|
182
|
+
# Step 1: Extract ZIP (with Zip Slip protection)
|
|
183
|
+
info_logger("Extracting bundle...")
|
|
184
|
+
with zipfile.ZipFile(bundle_path, 'r') as zip_ref:
|
|
185
|
+
for entry in zip_ref.namelist():
|
|
186
|
+
resolved = (temp_path / entry).resolve()
|
|
187
|
+
if not str(resolved).startswith(str(temp_path.resolve())):
|
|
188
|
+
return False, f"Zip Slip detected: entry '{entry}' escapes target directory"
|
|
189
|
+
zip_ref.extractall(temp_path)
|
|
190
|
+
|
|
191
|
+
# Step 2: Validate bundle
|
|
192
|
+
info_logger("Validating bundle...")
|
|
193
|
+
is_valid, validation_msg = self._validate_bundle(temp_path)
|
|
194
|
+
if not is_valid:
|
|
195
|
+
return False, f"Invalid bundle: {validation_msg}"
|
|
196
|
+
|
|
197
|
+
# Step 3: Load metadata
|
|
198
|
+
with open(temp_path / "metadata.json", 'r') as f:
|
|
199
|
+
metadata = json.load(f)
|
|
200
|
+
|
|
201
|
+
info_logger(f"Loading bundle: {metadata.get('repo', 'unknown')}")
|
|
202
|
+
info_logger(f"Bundle version: {metadata.get('cgc_version', 'unknown')}")
|
|
203
|
+
|
|
204
|
+
# Step 4: Handle existing data
|
|
205
|
+
repo_name = metadata.get('repo', 'unknown')
|
|
206
|
+
repo_path = metadata.get('repo_path')
|
|
207
|
+
|
|
208
|
+
if clear_existing:
|
|
209
|
+
# User explicitly wants to clear - remove everything
|
|
210
|
+
info_logger("Clearing all existing graph data...")
|
|
211
|
+
self._clear_graph()
|
|
212
|
+
else:
|
|
213
|
+
# Check if this repository already exists (only when NOT clearing)
|
|
214
|
+
existing_repo = self._check_existing_repository(repo_name, repo_path)
|
|
215
|
+
|
|
216
|
+
if existing_repo:
|
|
217
|
+
return False, f"Repository '{repo_name}' already exists in the database. Use clear_existing=True to replace it."
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Step 5: Create schema
|
|
221
|
+
info_logger("Creating schema...")
|
|
222
|
+
self._import_schema(temp_path / "schema.json")
|
|
223
|
+
|
|
224
|
+
# Step 6: Import nodes
|
|
225
|
+
info_logger("Importing nodes...")
|
|
226
|
+
node_count = self._import_nodes(temp_path / "nodes.jsonl")
|
|
227
|
+
|
|
228
|
+
# Step 7: Import edges
|
|
229
|
+
info_logger("Importing edges...")
|
|
230
|
+
edge_count = self._import_edges(temp_path / "edges.jsonl")
|
|
231
|
+
|
|
232
|
+
success_msg = f"✅ Successfully imported {bundle_path.name}\n"
|
|
233
|
+
success_msg += f" Repository: {metadata.get('repo', 'unknown')}\n"
|
|
234
|
+
success_msg += f" Nodes: {node_count:,} | Edges: {edge_count:,}"
|
|
235
|
+
info_logger(success_msg)
|
|
236
|
+
return True, success_msg
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
error_msg = f"Failed to import bundle: {str(e)}"
|
|
240
|
+
error_logger(error_msg)
|
|
241
|
+
return False, error_msg
|
|
242
|
+
|
|
243
|
+
# ========================================================================
|
|
244
|
+
# EXPORT HELPERS
|
|
245
|
+
# ========================================================================
|
|
246
|
+
|
|
247
|
+
def _extract_metadata(self, repo_path: Optional[Path]) -> Dict[str, Any]:
|
|
248
|
+
"""Extract metadata about the repository and indexing process."""
|
|
249
|
+
metadata = {
|
|
250
|
+
"cgc_version": self.VERSION,
|
|
251
|
+
"exported_at": datetime.now().isoformat(),
|
|
252
|
+
"format_version": "1.0"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Get repository information
|
|
256
|
+
with self.db_manager.get_driver().session() as session:
|
|
257
|
+
if repo_path:
|
|
258
|
+
# Specific repository
|
|
259
|
+
result = session.run(
|
|
260
|
+
"MATCH (r:Repository {path: $path}) RETURN r",
|
|
261
|
+
path=str(repo_path.resolve())
|
|
262
|
+
)
|
|
263
|
+
repo_node = result.single()
|
|
264
|
+
if repo_node:
|
|
265
|
+
node = repo_node['r']
|
|
266
|
+
# Convert Node to dict (handle both Neo4j and FalkorDB)
|
|
267
|
+
try:
|
|
268
|
+
repo = dict(node)
|
|
269
|
+
except TypeError:
|
|
270
|
+
# FalkorDB nodes - access properties directly
|
|
271
|
+
repo = {}
|
|
272
|
+
if hasattr(node, '_properties'):
|
|
273
|
+
repo = dict(node._properties)
|
|
274
|
+
elif hasattr(node, 'properties'):
|
|
275
|
+
repo = dict(node.properties)
|
|
276
|
+
else:
|
|
277
|
+
# Fallback: try to get individual properties
|
|
278
|
+
for attr in ['name', 'path', 'is_dependency']:
|
|
279
|
+
if hasattr(node, attr):
|
|
280
|
+
repo[attr] = getattr(node, attr)
|
|
281
|
+
|
|
282
|
+
metadata["repo"] = repo.get('name', str(repo_path))
|
|
283
|
+
metadata["repo_path"] = repo.get('path')
|
|
284
|
+
metadata["is_dependency"] = repo.get('is_dependency', False)
|
|
285
|
+
else:
|
|
286
|
+
# All repositories
|
|
287
|
+
result = session.run(
|
|
288
|
+
"MATCH (r:Repository) RETURN r.name as name, r.path as path"
|
|
289
|
+
)
|
|
290
|
+
repos = [{"name": record["name"], "path": record["path"]} for record in result]
|
|
291
|
+
metadata["repositories"] = repos
|
|
292
|
+
metadata["repo"] = "multiple" if len(repos) > 1 else repos[0]["name"] if repos else "unknown"
|
|
293
|
+
|
|
294
|
+
# Try to get git information if available
|
|
295
|
+
if repo_path and repo_path.exists():
|
|
296
|
+
try:
|
|
297
|
+
commit = subprocess.check_output(
|
|
298
|
+
['git', 'rev-parse', 'HEAD'],
|
|
299
|
+
cwd=repo_path,
|
|
300
|
+
stderr=subprocess.DEVNULL
|
|
301
|
+
).decode().strip()
|
|
302
|
+
metadata["commit"] = commit[:8]
|
|
303
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
304
|
+
pass
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
result = session.run("""
|
|
308
|
+
MATCH (f:File)
|
|
309
|
+
WHERE f.path STARTS WITH $repo_path
|
|
310
|
+
RETURN f.language as language, count(*) as count
|
|
311
|
+
ORDER BY count DESC
|
|
312
|
+
""", repo_path=str(repo_path.resolve()))
|
|
313
|
+
languages = {record["language"]: record["count"] for record in result if record["language"]}
|
|
314
|
+
metadata["languages"] = list(languages.keys())
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
return metadata
|
|
319
|
+
|
|
320
|
+
def _extract_schema(self) -> Dict[str, Any]:
|
|
321
|
+
"""Extract the graph schema (node labels, relationship types, constraints)."""
|
|
322
|
+
schema = {
|
|
323
|
+
"node_labels": [],
|
|
324
|
+
"relationship_types": [],
|
|
325
|
+
"constraints": [],
|
|
326
|
+
"indexes": []
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
with self.db_manager.get_driver().session() as session:
|
|
330
|
+
# Get node labels
|
|
331
|
+
try:
|
|
332
|
+
result = session.run("CALL db.labels()")
|
|
333
|
+
labels = []
|
|
334
|
+
for record in result:
|
|
335
|
+
try:
|
|
336
|
+
labels.append(record[0])
|
|
337
|
+
except (KeyError, TypeError):
|
|
338
|
+
if hasattr(record, 'values'):
|
|
339
|
+
vals = list(record.values())
|
|
340
|
+
if vals:
|
|
341
|
+
labels.append(vals[0])
|
|
342
|
+
schema["node_labels"] = labels
|
|
343
|
+
except Exception:
|
|
344
|
+
schema["node_labels"] = []
|
|
345
|
+
|
|
346
|
+
# Get relationship types
|
|
347
|
+
try:
|
|
348
|
+
result = session.run("CALL db.relationshipTypes()")
|
|
349
|
+
rel_types = []
|
|
350
|
+
for record in result:
|
|
351
|
+
try:
|
|
352
|
+
rel_types.append(record[0])
|
|
353
|
+
except (KeyError, TypeError):
|
|
354
|
+
if hasattr(record, 'values'):
|
|
355
|
+
vals = list(record.values())
|
|
356
|
+
if vals:
|
|
357
|
+
rel_types.append(vals[0])
|
|
358
|
+
schema["relationship_types"] = rel_types
|
|
359
|
+
except Exception:
|
|
360
|
+
schema["relationship_types"] = []
|
|
361
|
+
|
|
362
|
+
# Get constraints (Neo4j specific, may not work on all backends)
|
|
363
|
+
try:
|
|
364
|
+
result = session.run("SHOW CONSTRAINTS")
|
|
365
|
+
schema["constraints"] = [dict(record) for record in result]
|
|
366
|
+
except:
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
# Get indexes
|
|
370
|
+
try:
|
|
371
|
+
result = session.run("SHOW INDEXES")
|
|
372
|
+
schema["indexes"] = [dict(record) for record in result]
|
|
373
|
+
except:
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
return schema
|
|
377
|
+
|
|
378
|
+
def _extract_nodes(self, output_file: Path, repo_path: Optional[Path]) -> int:
|
|
379
|
+
"""Extract all nodes to JSONL format."""
|
|
380
|
+
count = 0
|
|
381
|
+
|
|
382
|
+
with self.db_manager.get_driver().session() as session:
|
|
383
|
+
# Build query based on repo_path
|
|
384
|
+
if repo_path:
|
|
385
|
+
query = """
|
|
386
|
+
MATCH (n)
|
|
387
|
+
WHERE n.path STARTS WITH $repo_path
|
|
388
|
+
RETURN n, labels(n) as labels
|
|
389
|
+
"""
|
|
390
|
+
params = {"repo_path": str(repo_path.resolve())}
|
|
391
|
+
else:
|
|
392
|
+
query = "MATCH (n) RETURN n, labels(n) as labels"
|
|
393
|
+
params = {}
|
|
394
|
+
|
|
395
|
+
# Run query with proper parameter handling for both Neo4j and FalkorDB
|
|
396
|
+
try:
|
|
397
|
+
result = session.run(query, **params)
|
|
398
|
+
except TypeError:
|
|
399
|
+
# FalkorDB might not support **params, try without
|
|
400
|
+
result = session.run(query)
|
|
401
|
+
|
|
402
|
+
with open(output_file, 'w') as f:
|
|
403
|
+
for record in result:
|
|
404
|
+
node = record['n']
|
|
405
|
+
labels = record['labels']
|
|
406
|
+
|
|
407
|
+
# Convert node to dict (handle both Neo4j and FalkorDB)
|
|
408
|
+
try:
|
|
409
|
+
node_dict = dict(node)
|
|
410
|
+
except TypeError:
|
|
411
|
+
# FalkorDB nodes might not be directly convertible
|
|
412
|
+
node_dict = {}
|
|
413
|
+
if hasattr(node, '_properties'):
|
|
414
|
+
node_dict = dict(node._properties)
|
|
415
|
+
elif hasattr(node, 'properties'):
|
|
416
|
+
node_dict = dict(node.properties)
|
|
417
|
+
|
|
418
|
+
node_dict['_labels'] = labels
|
|
419
|
+
|
|
420
|
+
# Store internal ID for reference
|
|
421
|
+
if hasattr(node, 'element_id'):
|
|
422
|
+
node_dict['_id'] = node.element_id
|
|
423
|
+
elif hasattr(node, 'id'):
|
|
424
|
+
node_dict['_id'] = str(node.id)
|
|
425
|
+
|
|
426
|
+
f.write(json.dumps(node_dict, cls=_BundleEncoder) + '\n')
|
|
427
|
+
count += 1
|
|
428
|
+
|
|
429
|
+
return count
|
|
430
|
+
|
|
431
|
+
def _extract_edges(self, output_file: Path, repo_path: Optional[Path]) -> int:
|
|
432
|
+
"""Extract all relationships to JSONL format."""
|
|
433
|
+
count = 0
|
|
434
|
+
|
|
435
|
+
with self.db_manager.get_driver().session() as session:
|
|
436
|
+
# Build query based on repo_path
|
|
437
|
+
if repo_path:
|
|
438
|
+
query = """
|
|
439
|
+
MATCH (n)-[r]->(m)
|
|
440
|
+
WHERE (n.path STARTS WITH $repo_path)
|
|
441
|
+
OR (m.path STARTS WITH $repo_path)
|
|
442
|
+
RETURN n, r, m, type(r) as rel_type
|
|
443
|
+
"""
|
|
444
|
+
params = {"repo_path": str(repo_path.resolve())}
|
|
445
|
+
else:
|
|
446
|
+
query = "MATCH (n)-[r]->(m) RETURN n, r, m, type(r) as rel_type"
|
|
447
|
+
params = {}
|
|
448
|
+
|
|
449
|
+
# Run query with proper parameter handling for both Neo4j and FalkorDB
|
|
450
|
+
try:
|
|
451
|
+
result = session.run(query, **params)
|
|
452
|
+
except TypeError:
|
|
453
|
+
# FalkorDB might not support **params, try without
|
|
454
|
+
result = session.run(query)
|
|
455
|
+
|
|
456
|
+
with open(output_file, 'w') as f:
|
|
457
|
+
for record in result:
|
|
458
|
+
source = record['n']
|
|
459
|
+
target = record['m']
|
|
460
|
+
rel = record['r']
|
|
461
|
+
rel_type = record['rel_type']
|
|
462
|
+
|
|
463
|
+
# Get source and target IDs (handle both Neo4j and FalkorDB)
|
|
464
|
+
if hasattr(source, 'element_id'):
|
|
465
|
+
from_id = source.element_id
|
|
466
|
+
elif hasattr(source, 'id'):
|
|
467
|
+
from_id = str(source.id)
|
|
468
|
+
else:
|
|
469
|
+
from_id = str(id(source)) # Fallback
|
|
470
|
+
|
|
471
|
+
if hasattr(target, 'element_id'):
|
|
472
|
+
to_id = target.element_id
|
|
473
|
+
elif hasattr(target, 'id'):
|
|
474
|
+
to_id = str(target.id)
|
|
475
|
+
else:
|
|
476
|
+
to_id = str(id(target)) # Fallback
|
|
477
|
+
|
|
478
|
+
# Get relationship properties
|
|
479
|
+
try:
|
|
480
|
+
rel_props = dict(rel)
|
|
481
|
+
except TypeError:
|
|
482
|
+
rel_props = {}
|
|
483
|
+
if hasattr(rel, '_properties'):
|
|
484
|
+
rel_props = dict(rel._properties)
|
|
485
|
+
elif hasattr(rel, 'properties'):
|
|
486
|
+
rel_props = dict(rel.properties)
|
|
487
|
+
|
|
488
|
+
# Create edge representation
|
|
489
|
+
edge_dict = {
|
|
490
|
+
'from': from_id,
|
|
491
|
+
'to': to_id,
|
|
492
|
+
'type': rel_type,
|
|
493
|
+
'properties': rel_props
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
f.write(json.dumps(edge_dict, cls=_BundleEncoder) + '\n')
|
|
497
|
+
count += 1
|
|
498
|
+
|
|
499
|
+
return count
|
|
500
|
+
|
|
501
|
+
def _generate_stats(self, repo_path: Optional[Path], node_count: int, edge_count: int) -> Dict[str, Any]:
|
|
502
|
+
"""Generate statistics about the graph."""
|
|
503
|
+
stats = {
|
|
504
|
+
"total_nodes": node_count,
|
|
505
|
+
"total_edges": edge_count,
|
|
506
|
+
"generated_at": datetime.now().isoformat()
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
with self.db_manager.get_driver().session() as session:
|
|
510
|
+
# Count by node type
|
|
511
|
+
result = session.run("""
|
|
512
|
+
MATCH (n)
|
|
513
|
+
RETURN labels(n)[0] as label, count(*) as count
|
|
514
|
+
ORDER BY count DESC
|
|
515
|
+
""")
|
|
516
|
+
stats["nodes_by_type"] = {record["label"]: record["count"] for record in result if record["label"]}
|
|
517
|
+
|
|
518
|
+
# Count by relationship type
|
|
519
|
+
result = session.run("""
|
|
520
|
+
MATCH ()-[r]->()
|
|
521
|
+
RETURN type(r) as type, count(*) as count
|
|
522
|
+
ORDER BY count DESC
|
|
523
|
+
""")
|
|
524
|
+
stats["edges_by_type"] = {record["type"]: record["count"] for record in result}
|
|
525
|
+
|
|
526
|
+
# File count
|
|
527
|
+
if repo_path:
|
|
528
|
+
result = session.run(
|
|
529
|
+
"MATCH (f:File) WHERE f.path STARTS WITH $repo_path RETURN count(f) as count",
|
|
530
|
+
repo_path=str(repo_path.resolve())
|
|
531
|
+
)
|
|
532
|
+
else:
|
|
533
|
+
result = session.run("MATCH (f:File) RETURN count(f) as count")
|
|
534
|
+
|
|
535
|
+
file_count = result.single()
|
|
536
|
+
stats["files"] = file_count["count"] if file_count else 0
|
|
537
|
+
|
|
538
|
+
return stats
|
|
539
|
+
|
|
540
|
+
def _create_readme(self, output_file: Path, metadata: Dict, stats: Optional[Dict]):
|
|
541
|
+
"""Create a human-readable README for the bundle."""
|
|
542
|
+
readme_content = f"""# CodeGraphContext Bundle
|
|
543
|
+
|
|
544
|
+
## Repository Information
|
|
545
|
+
- **Repository**: {metadata.get('repo', 'Unknown')}
|
|
546
|
+
- **Exported**: {metadata.get('exported_at', 'Unknown')}
|
|
547
|
+
- **CGC Version**: {metadata.get('cgc_version', 'Unknown')}
|
|
548
|
+
"""
|
|
549
|
+
|
|
550
|
+
if 'commit' in metadata:
|
|
551
|
+
readme_content += f"- **Commit**: {metadata['commit']}\n"
|
|
552
|
+
|
|
553
|
+
if 'languages' in metadata:
|
|
554
|
+
readme_content += f"- **Languages**: {', '.join(metadata['languages'])}\n"
|
|
555
|
+
|
|
556
|
+
if stats:
|
|
557
|
+
readme_content += f"""
|
|
558
|
+
## Statistics
|
|
559
|
+
- **Total Nodes**: {stats.get('total_nodes', 0):,}
|
|
560
|
+
- **Total Edges**: {stats.get('total_edges', 0):,}
|
|
561
|
+
- **Files**: {stats.get('files', 0):,}
|
|
562
|
+
|
|
563
|
+
### Nodes by Type
|
|
564
|
+
"""
|
|
565
|
+
for label, count in stats.get('nodes_by_type', {}).items():
|
|
566
|
+
readme_content += f"- {label}: {count:,}\n"
|
|
567
|
+
|
|
568
|
+
readme_content += "\n### Edges by Type\n"
|
|
569
|
+
for rel_type, count in stats.get('edges_by_type', {}).items():
|
|
570
|
+
readme_content += f"- {rel_type}: {count:,}\n"
|
|
571
|
+
|
|
572
|
+
readme_content += """
|
|
573
|
+
## Usage
|
|
574
|
+
|
|
575
|
+
Load this bundle with:
|
|
576
|
+
```bash
|
|
577
|
+
cgc load <bundle-file>.cgc
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Or import into existing graph:
|
|
581
|
+
```bash
|
|
582
|
+
cgc import <bundle-file>.cgc
|
|
583
|
+
```
|
|
584
|
+
"""
|
|
585
|
+
|
|
586
|
+
with open(output_file, 'w') as f:
|
|
587
|
+
f.write(readme_content)
|
|
588
|
+
|
|
589
|
+
def _create_zip(self, source_dir: Path, output_file: Path):
|
|
590
|
+
"""Create a ZIP archive from the bundle directory."""
|
|
591
|
+
with zipfile.ZipFile(output_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
592
|
+
for path in source_dir.rglob('*'):
|
|
593
|
+
if path.is_file():
|
|
594
|
+
arcname = path.relative_to(source_dir)
|
|
595
|
+
zipf.write(path, arcname)
|
|
596
|
+
|
|
597
|
+
# ========================================================================
|
|
598
|
+
# IMPORT HELPERS
|
|
599
|
+
# ========================================================================
|
|
600
|
+
|
|
601
|
+
def _validate_bundle(self, bundle_dir: Path) -> Tuple[bool, str]:
|
|
602
|
+
"""Validate that the bundle contains all required files."""
|
|
603
|
+
required_files = ['metadata.json', 'schema.json', 'nodes.jsonl', 'edges.jsonl']
|
|
604
|
+
|
|
605
|
+
for file_name in required_files:
|
|
606
|
+
if not (bundle_dir / file_name).exists():
|
|
607
|
+
return False, f"Missing required file: {file_name}"
|
|
608
|
+
|
|
609
|
+
# Validate metadata
|
|
610
|
+
try:
|
|
611
|
+
with open(bundle_dir / "metadata.json", 'r') as f:
|
|
612
|
+
metadata = json.load(f)
|
|
613
|
+
if 'cgc_version' not in metadata:
|
|
614
|
+
return False, "Invalid metadata: missing cgc_version"
|
|
615
|
+
except json.JSONDecodeError as e:
|
|
616
|
+
return False, f"Invalid metadata.json: {e}"
|
|
617
|
+
|
|
618
|
+
return True, "Valid bundle"
|
|
619
|
+
|
|
620
|
+
def _check_existing_repository(self, repo_name: str, repo_path: Optional[str]) -> bool:
|
|
621
|
+
"""Check if a repository already exists in the database."""
|
|
622
|
+
with self.db_manager.get_driver().session() as session:
|
|
623
|
+
# Try to find by name first
|
|
624
|
+
result = session.run(
|
|
625
|
+
"MATCH (r:Repository {name: $name}) RETURN r LIMIT 1",
|
|
626
|
+
name=repo_name
|
|
627
|
+
)
|
|
628
|
+
if result.single():
|
|
629
|
+
return True
|
|
630
|
+
|
|
631
|
+
# If repo_path is provided, also check by path
|
|
632
|
+
if repo_path:
|
|
633
|
+
result = session.run(
|
|
634
|
+
"MATCH (r:Repository {path: $path}) RETURN r LIMIT 1",
|
|
635
|
+
path=repo_path
|
|
636
|
+
)
|
|
637
|
+
if result.single():
|
|
638
|
+
return True
|
|
639
|
+
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
def _delete_repository(self, repo_identifier: str):
|
|
643
|
+
"""Delete a specific repository and all its related nodes from the graph."""
|
|
644
|
+
with self.db_manager.get_driver().session() as session:
|
|
645
|
+
# First, try to find the repository by name or path
|
|
646
|
+
result = session.run("""
|
|
647
|
+
MATCH (r:Repository)
|
|
648
|
+
WHERE r.name = $identifier OR r.path = $identifier
|
|
649
|
+
RETURN r.path as path
|
|
650
|
+
LIMIT 1
|
|
651
|
+
""", identifier=repo_identifier)
|
|
652
|
+
|
|
653
|
+
record = result.single()
|
|
654
|
+
if not record:
|
|
655
|
+
warning_logger(f"Repository '{repo_identifier}' not found for deletion")
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
repo_path = record['path']
|
|
659
|
+
|
|
660
|
+
# Delete all nodes that belong to this repository
|
|
661
|
+
# Files, Functions, Classes, Modules all have paths that start with repo_path
|
|
662
|
+
session.run("""
|
|
663
|
+
MATCH (n)
|
|
664
|
+
WHERE n.path STARTS WITH $repo_path
|
|
665
|
+
DETACH DELETE n
|
|
666
|
+
""", repo_path=repo_path)
|
|
667
|
+
|
|
668
|
+
# Delete the repository node itself
|
|
669
|
+
session.run("""
|
|
670
|
+
MATCH (r:Repository)
|
|
671
|
+
WHERE r.path = $repo_path
|
|
672
|
+
DELETE r
|
|
673
|
+
""", repo_path=repo_path)
|
|
674
|
+
|
|
675
|
+
info_logger(f"Deleted repository: {repo_identifier}")
|
|
676
|
+
|
|
677
|
+
def _clear_graph(self):
|
|
678
|
+
"""Clear all nodes and relationships from the graph in batches."""
|
|
679
|
+
with self.db_manager.get_driver().session() as session:
|
|
680
|
+
while True:
|
|
681
|
+
result = session.run(
|
|
682
|
+
"MATCH (n) WITH n LIMIT 500 DETACH DELETE n RETURN count(n) as deleted"
|
|
683
|
+
)
|
|
684
|
+
record = result.single()
|
|
685
|
+
if not record or record["deleted"] == 0:
|
|
686
|
+
break
|
|
687
|
+
|
|
688
|
+
def _import_schema(self, schema_file: Path):
|
|
689
|
+
"""Import schema (constraints and indexes)."""
|
|
690
|
+
with open(schema_file, 'r') as f:
|
|
691
|
+
schema = json.load(f)
|
|
692
|
+
|
|
693
|
+
# Note: Schema import is complex and database-specific
|
|
694
|
+
# For now, we'll rely on the application to create the schema
|
|
695
|
+
# This is a placeholder for future enhancement
|
|
696
|
+
debug_log("Schema import not yet implemented - relying on application schema")
|
|
697
|
+
|
|
698
|
+
def _import_nodes(self, nodes_file: Path) -> int:
|
|
699
|
+
"""Import nodes from JSONL file."""
|
|
700
|
+
count = 0
|
|
701
|
+
batch_size = 1000
|
|
702
|
+
batch = []
|
|
703
|
+
|
|
704
|
+
# Create a mapping from old IDs to new IDs
|
|
705
|
+
id_mapping = {}
|
|
706
|
+
|
|
707
|
+
with self.db_manager.get_driver().session() as session:
|
|
708
|
+
with open(nodes_file, 'r') as f:
|
|
709
|
+
for line in f:
|
|
710
|
+
node_data = json.loads(line)
|
|
711
|
+
|
|
712
|
+
# Extract labels and old ID (handle both Neo4j and KuzuDB formats)
|
|
713
|
+
labels = node_data.pop('_labels', None) or node_data.pop('_label', None) or []
|
|
714
|
+
if isinstance(labels, str):
|
|
715
|
+
labels = [labels]
|
|
716
|
+
old_id = node_data.pop('_id', None)
|
|
717
|
+
# Convert dict IDs to hashable tuples for mapping
|
|
718
|
+
if isinstance(old_id, dict):
|
|
719
|
+
old_id = (old_id.get('table', 0), old_id.get('offset', 0))
|
|
720
|
+
|
|
721
|
+
# Remove internal properties
|
|
722
|
+
node_data.pop('_element_id', None)
|
|
723
|
+
|
|
724
|
+
batch.append((labels, node_data, old_id))
|
|
725
|
+
|
|
726
|
+
if len(batch) >= batch_size:
|
|
727
|
+
count += self._import_node_batch(session, batch, id_mapping)
|
|
728
|
+
batch = []
|
|
729
|
+
|
|
730
|
+
# Import remaining nodes
|
|
731
|
+
if batch:
|
|
732
|
+
count += self._import_node_batch(session, batch, id_mapping)
|
|
733
|
+
|
|
734
|
+
# Store ID mapping for edge import
|
|
735
|
+
self._id_mapping = id_mapping
|
|
736
|
+
|
|
737
|
+
return count
|
|
738
|
+
|
|
739
|
+
_PK_MAP = {
|
|
740
|
+
'Repository': 'path', 'File': 'path', 'Directory': 'path',
|
|
741
|
+
'Module': 'name',
|
|
742
|
+
'Function': 'uid', 'Class': 'uid', 'Variable': 'uid',
|
|
743
|
+
'Trait': 'uid', 'Interface': 'uid', 'Macro': 'uid',
|
|
744
|
+
'Struct': 'uid', 'Enum': 'uid', 'Union': 'uid',
|
|
745
|
+
'Annotation': 'uid', 'Record': 'uid', 'Property': 'uid',
|
|
746
|
+
'Parameter': 'uid',
|
|
747
|
+
}
|
|
748
|
+
_UID_PARTS = {
|
|
749
|
+
'Function': ['name', 'path', 'line_number'],
|
|
750
|
+
'Class': ['name', 'path', 'line_number'],
|
|
751
|
+
'Variable': ['name', 'path', 'line_number'],
|
|
752
|
+
'Trait': ['name', 'path', 'line_number'],
|
|
753
|
+
'Interface': ['name', 'path', 'line_number'],
|
|
754
|
+
'Macro': ['name', 'path', 'line_number'],
|
|
755
|
+
'Struct': ['name', 'path', 'line_number'],
|
|
756
|
+
'Enum': ['name', 'path', 'line_number'],
|
|
757
|
+
'Union': ['name', 'path', 'line_number'],
|
|
758
|
+
'Annotation': ['name', 'path', 'line_number'],
|
|
759
|
+
'Record': ['name', 'path', 'line_number'],
|
|
760
|
+
'Property': ['name', 'path', 'line_number'],
|
|
761
|
+
'Parameter': ['name', 'path', 'function_line_number'],
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> int:
|
|
765
|
+
"""Import a batch of nodes."""
|
|
766
|
+
id_function = self._get_id_function()
|
|
767
|
+
|
|
768
|
+
for labels, properties, old_id in batch:
|
|
769
|
+
if not labels:
|
|
770
|
+
continue
|
|
771
|
+
|
|
772
|
+
if isinstance(labels, str):
|
|
773
|
+
labels = [labels]
|
|
774
|
+
label_str = ':'.join(labels)
|
|
775
|
+
primary_label = labels[0]
|
|
776
|
+
|
|
777
|
+
pk_field = self._PK_MAP.get(primary_label)
|
|
778
|
+
if pk_field == 'uid' and 'uid' not in properties:
|
|
779
|
+
parts = self._UID_PARTS.get(primary_label, [])
|
|
780
|
+
properties['uid'] = ''.join(str(properties.get(p, '')) for p in parts)
|
|
781
|
+
|
|
782
|
+
if pk_field and pk_field in properties:
|
|
783
|
+
pk_val = properties[pk_field]
|
|
784
|
+
remaining = {k: v for k, v in properties.items() if k != pk_field}
|
|
785
|
+
query = (
|
|
786
|
+
f"MERGE (n:{label_str} {{{pk_field}: $pk_val}}) "
|
|
787
|
+
f"SET n += $props RETURN {id_function}(n) as new_id"
|
|
788
|
+
)
|
|
789
|
+
result = session.run(query, pk_val=pk_val, props=remaining)
|
|
790
|
+
else:
|
|
791
|
+
query = f"CREATE (n:{label_str}) SET n = $props RETURN {id_function}(n) as new_id"
|
|
792
|
+
result = session.run(query, props=properties)
|
|
793
|
+
|
|
794
|
+
record = result.single()
|
|
795
|
+
if record and old_id:
|
|
796
|
+
id_mapping[old_id] = record['new_id']
|
|
797
|
+
|
|
798
|
+
return len(batch)
|
|
799
|
+
|
|
800
|
+
def _import_edges(self, edges_file: Path) -> int:
|
|
801
|
+
"""Import edges from JSONL file."""
|
|
802
|
+
count = 0
|
|
803
|
+
batch_size = 1000
|
|
804
|
+
batch = []
|
|
805
|
+
|
|
806
|
+
with self.db_manager.get_driver().session() as session:
|
|
807
|
+
with open(edges_file, 'r') as f:
|
|
808
|
+
for line in f:
|
|
809
|
+
edge_data = json.loads(line)
|
|
810
|
+
batch.append(edge_data)
|
|
811
|
+
|
|
812
|
+
if len(batch) >= batch_size:
|
|
813
|
+
count += self._import_edge_batch(session, batch)
|
|
814
|
+
batch = []
|
|
815
|
+
|
|
816
|
+
# Import remaining edges
|
|
817
|
+
if batch:
|
|
818
|
+
count += self._import_edge_batch(session, batch)
|
|
819
|
+
|
|
820
|
+
return count
|
|
821
|
+
|
|
822
|
+
def _import_edge_batch(self, session, batch: List[Dict]) -> int:
|
|
823
|
+
"""Import a batch of edges."""
|
|
824
|
+
id_mapping = getattr(self, '_id_mapping', {})
|
|
825
|
+
# Detect database backend to use appropriate ID function
|
|
826
|
+
id_function = self._get_id_function()
|
|
827
|
+
|
|
828
|
+
for edge in batch:
|
|
829
|
+
old_from = edge.get('from')
|
|
830
|
+
old_to = edge.get('to')
|
|
831
|
+
# Convert dict IDs to hashable tuples (matches node import conversion)
|
|
832
|
+
if isinstance(old_from, dict):
|
|
833
|
+
old_from = (old_from.get('table', 0), old_from.get('offset', 0))
|
|
834
|
+
if isinstance(old_to, dict):
|
|
835
|
+
old_to = (old_to.get('table', 0), old_to.get('offset', 0))
|
|
836
|
+
rel_type = edge.get('type')
|
|
837
|
+
properties = edge.get('properties', {})
|
|
838
|
+
|
|
839
|
+
# Map old IDs to new IDs
|
|
840
|
+
new_from = id_mapping.get(old_from)
|
|
841
|
+
new_to = id_mapping.get(old_to)
|
|
842
|
+
|
|
843
|
+
if not new_from or not new_to:
|
|
844
|
+
warning_logger(f"Skipping edge: node IDs not found in mapping")
|
|
845
|
+
continue
|
|
846
|
+
|
|
847
|
+
# Create relationship
|
|
848
|
+
query = f"""
|
|
849
|
+
MATCH (a), (b)
|
|
850
|
+
WHERE {id_function}(a) = $from_id AND {id_function}(b) = $to_id
|
|
851
|
+
CREATE (a)-[r:{rel_type}]->(b)
|
|
852
|
+
SET r = $props
|
|
853
|
+
"""
|
|
854
|
+
|
|
855
|
+
session.run(query, from_id=new_from, to_id=new_to, props=properties)
|
|
856
|
+
|
|
857
|
+
return len(batch)
|
|
858
|
+
|