graflo 1.3.7__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.

Potentially problematic release.


This version of graflo might be problematic. Click here for more details.

Files changed (70) hide show
  1. graflo/README.md +18 -0
  2. graflo/__init__.py +70 -0
  3. graflo/architecture/__init__.py +38 -0
  4. graflo/architecture/actor.py +1276 -0
  5. graflo/architecture/actor_util.py +450 -0
  6. graflo/architecture/edge.py +418 -0
  7. graflo/architecture/onto.py +376 -0
  8. graflo/architecture/onto_sql.py +54 -0
  9. graflo/architecture/resource.py +163 -0
  10. graflo/architecture/schema.py +135 -0
  11. graflo/architecture/transform.py +292 -0
  12. graflo/architecture/util.py +89 -0
  13. graflo/architecture/vertex.py +562 -0
  14. graflo/caster.py +736 -0
  15. graflo/cli/__init__.py +14 -0
  16. graflo/cli/ingest.py +203 -0
  17. graflo/cli/manage_dbs.py +197 -0
  18. graflo/cli/plot_schema.py +132 -0
  19. graflo/cli/xml2json.py +93 -0
  20. graflo/data_source/__init__.py +48 -0
  21. graflo/data_source/api.py +339 -0
  22. graflo/data_source/base.py +95 -0
  23. graflo/data_source/factory.py +304 -0
  24. graflo/data_source/file.py +148 -0
  25. graflo/data_source/memory.py +70 -0
  26. graflo/data_source/registry.py +82 -0
  27. graflo/data_source/sql.py +183 -0
  28. graflo/db/__init__.py +44 -0
  29. graflo/db/arango/__init__.py +22 -0
  30. graflo/db/arango/conn.py +1025 -0
  31. graflo/db/arango/query.py +180 -0
  32. graflo/db/arango/util.py +88 -0
  33. graflo/db/conn.py +377 -0
  34. graflo/db/connection/__init__.py +6 -0
  35. graflo/db/connection/config_mapping.py +18 -0
  36. graflo/db/connection/onto.py +717 -0
  37. graflo/db/connection/wsgi.py +29 -0
  38. graflo/db/manager.py +119 -0
  39. graflo/db/neo4j/__init__.py +16 -0
  40. graflo/db/neo4j/conn.py +639 -0
  41. graflo/db/postgres/__init__.py +37 -0
  42. graflo/db/postgres/conn.py +948 -0
  43. graflo/db/postgres/fuzzy_matcher.py +281 -0
  44. graflo/db/postgres/heuristics.py +133 -0
  45. graflo/db/postgres/inference_utils.py +428 -0
  46. graflo/db/postgres/resource_mapping.py +273 -0
  47. graflo/db/postgres/schema_inference.py +372 -0
  48. graflo/db/postgres/types.py +148 -0
  49. graflo/db/postgres/util.py +87 -0
  50. graflo/db/tigergraph/__init__.py +9 -0
  51. graflo/db/tigergraph/conn.py +2365 -0
  52. graflo/db/tigergraph/onto.py +26 -0
  53. graflo/db/util.py +49 -0
  54. graflo/filter/__init__.py +21 -0
  55. graflo/filter/onto.py +525 -0
  56. graflo/logging.conf +22 -0
  57. graflo/onto.py +312 -0
  58. graflo/plot/__init__.py +17 -0
  59. graflo/plot/plotter.py +616 -0
  60. graflo/util/__init__.py +23 -0
  61. graflo/util/chunker.py +807 -0
  62. graflo/util/merge.py +150 -0
  63. graflo/util/misc.py +37 -0
  64. graflo/util/onto.py +422 -0
  65. graflo/util/transform.py +454 -0
  66. graflo-1.3.7.dist-info/METADATA +243 -0
  67. graflo-1.3.7.dist-info/RECORD +70 -0
  68. graflo-1.3.7.dist-info/WHEEL +4 -0
  69. graflo-1.3.7.dist-info/entry_points.txt +5 -0
  70. graflo-1.3.7.dist-info/licenses/LICENSE +126 -0
@@ -0,0 +1,2365 @@
1
+ """TigerGraph connection implementation for graph database operations.
2
+
3
+ This module implements the Connection interface for TigerGraph, providing
4
+ specific functionality for graph operations in TigerGraph. It handles:
5
+ - Vertex and edge management
6
+ - GSQL query execution
7
+ - Schema management
8
+ - Batch operations
9
+ - Graph traversal and analytics
10
+
11
+ Key Features:
12
+ - Vertex and edge type management
13
+ - GSQL query execution
14
+ - Schema definition and management
15
+ - Batch vertex and edge operations
16
+ - Graph analytics and traversal
17
+
18
+ Example:
19
+ >>> conn = TigerGraphConnection(config)
20
+ >>> conn.init_db(schema, clean_start=True)
21
+ >>> conn.upsert_docs_batch(docs, "User", match_keys=["email"])
22
+ """
23
+
24
+ import contextlib
25
+ import json
26
+ import logging
27
+ from typing import Any, cast
28
+
29
+
30
+ import requests
31
+ from requests import exceptions as requests_exceptions
32
+
33
+ from pyTigerGraph import TigerGraphConnection as PyTigerGraphConnection
34
+
35
+
36
+ from graflo.architecture.edge import Edge
37
+ from graflo.architecture.onto import Index
38
+ from graflo.architecture.schema import Schema
39
+ from graflo.architecture.vertex import FieldType, Vertex, VertexConfig
40
+ from graflo.db.conn import Connection
41
+ from graflo.db.connection.onto import TigergraphConfig
42
+ from graflo.db.tigergraph.onto import (
43
+ TIGERGRAPH_TYPE_ALIASES,
44
+ VALID_TIGERGRAPH_TYPES,
45
+ )
46
+ from graflo.filter.onto import Clause, Expression
47
+ from graflo.onto import AggregationType, DBFlavor, ExpressionFlavor
48
+ from graflo.util.transform import pick_unique_dict
49
+ from urllib.parse import quote
50
+
51
+
52
+ def _json_serializer(obj):
53
+ """JSON serializer for objects not serializable by default json code.
54
+
55
+ Handles datetime, date, time, and other non-serializable types.
56
+ Decimal should already be converted to float at the data source level.
57
+
58
+ Args:
59
+ obj: Object to serialize
60
+
61
+ Returns:
62
+ JSON-serializable representation
63
+ """
64
+ from datetime import date, datetime, time
65
+
66
+ if isinstance(obj, (datetime, date, time)):
67
+ return obj.isoformat()
68
+ # Decimal should be converted to float at source (SQLDataSource)
69
+ # But handle it here as a fallback
70
+ from decimal import Decimal
71
+
72
+ if isinstance(obj, Decimal):
73
+ return float(obj)
74
+ raise TypeError(f"Type {type(obj)} not serializable")
75
+
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ class TigerGraphConnection(Connection):
81
+ """
82
+ TigerGraph database connection implementation.
83
+
84
+ Key conceptual differences from ArangoDB:
85
+ 1. TigerGraph uses GSQL (Graph Query Language) instead of AQL
86
+ 2. Schema must be defined explicitly before data insertion
87
+ 3. No automatic collection creation - vertices and edges must be pre-defined
88
+ 4. Different query syntax and execution model
89
+ 5. Token-based authentication for some operations
90
+ """
91
+
92
+ flavor = DBFlavor.TIGERGRAPH
93
+
94
+ def __init__(self, config: TigergraphConfig):
95
+ super().__init__()
96
+ self.config = config
97
+ # Store base URLs for REST++ and GSQL endpoints
98
+ self.restpp_url = f"{config.url_without_port}:{config.port}"
99
+ self.gsql_url = f"{config.url_without_port}:{config.gs_port}"
100
+
101
+ # Initialize pyTigerGraph connection for most operations
102
+ # Use type narrowing to help type checker understand non-None values
103
+ # PyTigerGraphConnection has defaults for all parameters, so None values are acceptable
104
+ restpp_port: int | str = config.port if config.port is not None else "9000"
105
+ gs_port: int | str = config.gs_port if config.gs_port is not None else "14240"
106
+ graphname: str = (
107
+ config.database if config.database is not None else "DefaultGraph"
108
+ )
109
+ username: str = config.username if config.username is not None else "tigergraph"
110
+ password: str = config.password if config.password is not None else "tigergraph"
111
+ cert_path: str | None = getattr(config, "certPath", None)
112
+
113
+ # Build connection kwargs, only include certPath if it's not None
114
+ conn_kwargs: dict[str, Any] = {
115
+ "host": config.url_without_port,
116
+ "restppPort": restpp_port,
117
+ "gsPort": gs_port,
118
+ "graphname": graphname,
119
+ "username": username,
120
+ "password": password,
121
+ }
122
+ if cert_path is not None:
123
+ conn_kwargs["certPath"] = cert_path
124
+
125
+ self.conn = PyTigerGraphConnection(**conn_kwargs)
126
+
127
+ # Get authentication token if secret is provided
128
+ if config.secret:
129
+ try:
130
+ self.conn.getToken(config.secret)
131
+ except Exception as e:
132
+ logger.warning(f"Failed to get authentication token: {e}")
133
+
134
+ def _get_auth_headers(self) -> dict[str, str]:
135
+ """Get HTTP Basic Auth headers if credentials are available.
136
+
137
+ Returns:
138
+ Dictionary with Authorization header if credentials exist
139
+ """
140
+ headers = {}
141
+ if self.config.username and self.config.password:
142
+ import base64
143
+
144
+ credentials = f"{self.config.username}:{self.config.password}"
145
+ encoded_credentials = base64.b64encode(credentials.encode()).decode()
146
+ headers["Authorization"] = f"Basic {encoded_credentials}"
147
+ return headers
148
+
149
+ def _call_restpp_api(
150
+ self,
151
+ endpoint: str,
152
+ method: str = "GET",
153
+ data: dict[str, Any] | None = None,
154
+ params: dict[str, str] | None = None,
155
+ ) -> dict[str, Any] | list[dict]:
156
+ """Call TigerGraph REST++ API endpoint.
157
+
158
+ Args:
159
+ endpoint: REST++ API endpoint (e.g., "/graph/{graph_name}/vertices/{vertex_type}")
160
+ method: HTTP method (GET, POST, etc.)
161
+ data: Optional data to send in request body (for POST)
162
+ params: Optional query parameters
163
+
164
+ Returns:
165
+ Response data (dict or list)
166
+ """
167
+ url = f"{self.restpp_url}{endpoint}"
168
+
169
+ headers = {
170
+ "Content-Type": "application/json",
171
+ **self._get_auth_headers(),
172
+ }
173
+
174
+ logger.debug(f"REST++ API call: {method} {url}")
175
+
176
+ try:
177
+ if method.upper() == "GET":
178
+ response = requests.get(
179
+ url, headers=headers, params=params, timeout=120
180
+ )
181
+ elif method.upper() == "POST":
182
+ response = requests.post(
183
+ url,
184
+ headers=headers,
185
+ data=json.dumps(data, default=_json_serializer) if data else None,
186
+ params=params,
187
+ timeout=120,
188
+ )
189
+ elif method.upper() == "DELETE":
190
+ response = requests.delete(
191
+ url, headers=headers, params=params, timeout=120
192
+ )
193
+ else:
194
+ raise ValueError(f"Unsupported HTTP method: {method}")
195
+
196
+ response.raise_for_status()
197
+ return response.json()
198
+
199
+ except requests_exceptions.HTTPError as errh:
200
+ logger.error(f"HTTP Error: {errh}")
201
+ error_response = {"error": True, "message": str(errh)}
202
+ try:
203
+ # Try to parse error response for more details
204
+ error_json = response.json()
205
+ if isinstance(error_json, dict):
206
+ error_response.update(error_json)
207
+ else:
208
+ error_response["details"] = response.text
209
+ except Exception:
210
+ error_response["details"] = response.text
211
+ return error_response
212
+ except requests_exceptions.ConnectionError as errc:
213
+ logger.error(f"Error Connecting: {errc}")
214
+ return {"error": True, "message": str(errc)}
215
+ except requests_exceptions.Timeout as errt:
216
+ logger.error(f"Timeout Error: {errt}")
217
+ return {"error": True, "message": str(errt)}
218
+ except requests_exceptions.RequestException as err:
219
+ logger.error(f"An unexpected error occurred: {err}")
220
+ return {"error": True, "message": str(err)}
221
+
222
+ @contextlib.contextmanager
223
+ def _ensure_graph_context(self, graph_name: str | None = None):
224
+ """
225
+ Context manager that ensures graph context for metadata operations.
226
+
227
+ Updates conn.graphname for PyTigerGraph metadata operations that rely on it
228
+ (e.g., getVertexTypes(), getEdgeTypes()).
229
+
230
+ Args:
231
+ graph_name: Name of the graph to use. If None, uses self.config.database.
232
+
233
+ Yields:
234
+ The graph name that was set.
235
+ """
236
+ graph_name = graph_name or self.config.database
237
+ if not graph_name:
238
+ raise ValueError(
239
+ "Graph name must be provided via graph_name parameter or config.database"
240
+ )
241
+
242
+ old_graphname = self.conn.graphname
243
+ self.conn.graphname = graph_name
244
+
245
+ try:
246
+ yield graph_name
247
+ finally:
248
+ # Restore original graphname
249
+ self.conn.graphname = old_graphname
250
+
251
+ def graph_exists(self, name: str) -> bool:
252
+ """
253
+ Check if a graph with the given name exists.
254
+
255
+ Uses the USE GRAPH command and checks the returned message.
256
+ If the graph doesn't exist, USE GRAPH returns an error message like
257
+ "Graph 'name' does not exist."
258
+
259
+ Args:
260
+ name: Name of the graph to check
261
+
262
+ Returns:
263
+ bool: True if the graph exists, False otherwise
264
+ """
265
+ try:
266
+ result = self.conn.gsql(f"USE GRAPH {name}")
267
+ result_str = str(result).lower()
268
+
269
+ # If the graph doesn't exist, USE GRAPH returns an error message
270
+ # Check for common error messages indicating the graph doesn't exist
271
+ error_patterns = [
272
+ "does not exist",
273
+ "doesn't exist",
274
+ "doesn't exist!",
275
+ f"graph '{name.lower()}' does not exist",
276
+ ]
277
+
278
+ # If any error pattern is found, the graph doesn't exist
279
+ for pattern in error_patterns:
280
+ if pattern in result_str:
281
+ return False
282
+
283
+ # If no error pattern is found, the graph likely exists
284
+ # (USE GRAPH succeeded or returned success message)
285
+ return True
286
+ except Exception as e:
287
+ logger.debug(f"Error checking if graph '{name}' exists: {e}")
288
+ # If there's an exception, try to parse it
289
+ error_str = str(e).lower()
290
+ if "does not exist" in error_str or "doesn't exist" in error_str:
291
+ return False
292
+ # If exception doesn't indicate "doesn't exist", assume it exists
293
+ # (other errors might indicate connection issues, not missing graph)
294
+ return False
295
+
296
+ def create_database(
297
+ self,
298
+ name: str,
299
+ vertex_names: list[str] | None = None,
300
+ edge_names: list[str] | None = None,
301
+ ):
302
+ """
303
+ Create a TigerGraph database (graph) using GSQL commands.
304
+
305
+ This method creates a graph with explicitly attached vertices and edges.
306
+ Example: CREATE GRAPH researchGraph (author, paper, wrote)
307
+
308
+ This method uses the pyTigerGraph gsql() method to execute GSQL commands
309
+ that create and use the graph. Supported in TigerGraph version 4.2.2+.
310
+
311
+ Args:
312
+ name: Name of the graph to create
313
+ vertex_names: Optional list of vertex type names to attach to the graph
314
+ edge_names: Optional list of edge type names to attach to the graph
315
+
316
+ Raises:
317
+ Exception: If graph creation fails
318
+ """
319
+ try:
320
+ # Build the list of types to include in CREATE GRAPH
321
+ all_types = []
322
+ if vertex_names:
323
+ all_types.extend(vertex_names)
324
+ if edge_names:
325
+ all_types.extend(edge_names)
326
+
327
+ # Format the CREATE GRAPH command with types
328
+ if all_types:
329
+ types_str = ", ".join(all_types)
330
+ gsql_commands = f"CREATE GRAPH {name} ({types_str})\nUSE GRAPH {name}"
331
+ else:
332
+ # Fallback to empty graph if no types provided
333
+ gsql_commands = f"CREATE GRAPH {name}()\nUSE GRAPH {name}"
334
+
335
+ # Execute using pyTigerGraph's gsql method which handles authentication
336
+ logger.debug(f"Creating graph '{name}' via GSQL: {gsql_commands}")
337
+ try:
338
+ result = self.conn.gsql(gsql_commands)
339
+ logger.info(
340
+ f"Successfully created graph '{name}' with types {all_types}: {result}"
341
+ )
342
+ return result
343
+ except Exception as e:
344
+ error_msg = str(e).lower()
345
+ # Check if graph already exists (might be acceptable)
346
+ if "already exists" in error_msg or "duplicate" in error_msg:
347
+ logger.info(f"Graph '{name}' may already exist: {e}")
348
+ return str(e)
349
+ logger.error(f"Failed to create graph '{name}': {e}")
350
+ raise
351
+
352
+ except Exception as e:
353
+ logger.error(f"Error creating graph '{name}' via GSQL: {e}")
354
+ raise
355
+
356
+ def delete_database(self, name: str):
357
+ """
358
+ Delete a TigerGraph database (graph).
359
+
360
+ This method attempts to drop the graph using GSQL DROP GRAPH.
361
+ If that fails (e.g., dependencies), it will:
362
+ 1) Remove associations and drop all edge types
363
+ 2) Drop all vertex types
364
+ 3) Clear remaining data as a last resort
365
+
366
+ Args:
367
+ name: Name of the graph to delete
368
+
369
+ Note:
370
+ In TigerGraph, deleting a graph structure requires the graph to be empty
371
+ or may fail if it has dependencies. This method handles both cases.
372
+ """
373
+ try:
374
+ logger.debug(f"Attempting to drop graph '{name}'")
375
+ try:
376
+ # Use the graph first to ensure we're working with the right graph
377
+ drop_command = f"USE GRAPH {name}\nDROP GRAPH {name}"
378
+ result = self.conn.gsql(drop_command)
379
+ logger.info(f"Successfully dropped graph '{name}': {result}")
380
+ return result
381
+ except Exception as e:
382
+ logger.debug(
383
+ f"Could not drop graph '{name}' (may not exist or have dependencies): {e}"
384
+ )
385
+
386
+ # Fallback 1: Attempt to drop edge and vertex types via ALTER GRAPH and DROP
387
+ try:
388
+ with self._ensure_graph_context(name):
389
+ # Drop edge associations and edge types
390
+ try:
391
+ edge_types = self.conn.getEdgeTypes(force=True)
392
+ except Exception:
393
+ edge_types = []
394
+
395
+ for e_type in edge_types:
396
+ # Try disassociate from graph (safe if already disassociated)
397
+ # ALTER GRAPH requires USE GRAPH context
398
+ try:
399
+ drop_edge_cmd = f"USE GRAPH {name}\nALTER GRAPH {name} DROP DIRECTED EDGE {e_type}"
400
+ self.conn.gsql(drop_edge_cmd)
401
+ except Exception:
402
+ pass
403
+ # Try drop edge type globally (edges are global, no USE GRAPH needed)
404
+ try:
405
+ drop_edge_global_cmd = f"DROP DIRECTED EDGE {e_type}"
406
+ self.conn.gsql(drop_edge_global_cmd)
407
+ except Exception:
408
+ pass
409
+
410
+ # Drop vertex associations and vertex types
411
+ try:
412
+ vertex_types = self.conn.getVertexTypes(force=True)
413
+ except Exception:
414
+ vertex_types = []
415
+
416
+ for v_type in vertex_types:
417
+ # Remove all data first to avoid dependency issues
418
+ try:
419
+ self.conn.delVertices(v_type)
420
+ except Exception:
421
+ pass
422
+ # Disassociate from graph (best-effort)
423
+ # ALTER GRAPH requires USE GRAPH context
424
+ try:
425
+ drop_vertex_cmd = f"USE GRAPH {name}\nALTER GRAPH {name} DROP VERTEX {v_type}"
426
+ self.conn.gsql(drop_vertex_cmd)
427
+ except Exception:
428
+ pass
429
+ # Drop vertex type globally (vertices are global, no USE GRAPH needed)
430
+ try:
431
+ drop_vertex_global_cmd = f"DROP VERTEX {v_type}"
432
+ self.conn.gsql(drop_vertex_global_cmd)
433
+ except Exception:
434
+ pass
435
+ except Exception as e3:
436
+ logger.warning(
437
+ f"Could not drop schema types for graph '{name}': {e3}. Proceeding to data clear."
438
+ )
439
+
440
+ # Fallback 2: Clear all data (if any remain)
441
+ try:
442
+ with self._ensure_graph_context(name):
443
+ vertex_types = self.conn.getVertexTypes()
444
+ for v_type in vertex_types:
445
+ result = self.conn.delVertices(v_type)
446
+ logger.debug(f"Cleared vertices of type {v_type}: {result}")
447
+ logger.info(f"Cleared all data from graph '{name}'")
448
+ except Exception as e2:
449
+ logger.warning(
450
+ f"Could not clear data from graph '{name}': {e2}. Graph may not exist."
451
+ )
452
+
453
+ except Exception as e:
454
+ logger.error(f"Error deleting database '{name}': {e}")
455
+
456
+ def execute(self, query, **kwargs):
457
+ """
458
+ Execute GSQL query or installed query based on content.
459
+ """
460
+ try:
461
+ # Check if this is an installed query call
462
+ if query.strip().upper().startswith("RUN "):
463
+ # Extract query name and parameters
464
+ query_name = query.strip()[4:].split("(")[0].strip()
465
+ result = self.conn.runInstalledQuery(query_name, **kwargs)
466
+ else:
467
+ # Execute as raw GSQL
468
+ result = self.conn.gsql(query)
469
+ return result
470
+ except Exception as e:
471
+ logger.error(f"Error executing query '{query}': {e}")
472
+ raise
473
+
474
+ def close(self):
475
+ """Close connection - pyTigerGraph handles cleanup automatically."""
476
+ pass
477
+
478
+ def init_db(self, schema: Schema, clean_start=False):
479
+ """
480
+ Initialize database with schema definition.
481
+
482
+ Follows the same pattern as ArangoDB:
483
+ 1. Clean if needed
484
+ 2. Create vertex and edge types globally (required before CREATE GRAPH)
485
+ 3. Create graph with vertices and edges explicitly attached
486
+ 4. Define indexes
487
+
488
+ If any step fails, the graph will be cleaned up gracefully.
489
+ """
490
+ # Use schema.general.name for graph creation
491
+ graph_created = False
492
+
493
+ # Determine graph name: use config.database if set, otherwise use schema.general.name
494
+ graph_name = self.config.database
495
+ if not graph_name:
496
+ graph_name = schema.general.name
497
+ # Update config for subsequent operations
498
+ self.config.database = graph_name
499
+ logger.info(f"Using schema name '{graph_name}' from schema.general.name")
500
+
501
+ try:
502
+ if clean_start:
503
+ try:
504
+ # Delete all graphs, edges, and vertices (full teardown)
505
+ self.delete_graph_structure([], [], delete_all=True)
506
+ logger.debug(f"Cleaned graph '{graph_name}' for fresh start")
507
+ except Exception as clean_error:
508
+ logger.warning(
509
+ f"Error during clean_start for graph '{graph_name}': {clean_error}",
510
+ exc_info=True,
511
+ )
512
+ # Continue - may be first run or already clean
513
+
514
+ # Step 1: Create vertex and edge types globally first
515
+ # These must exist before they can be included in CREATE GRAPH
516
+ logger.debug(
517
+ f"Creating vertex and edge types globally for graph '{graph_name}'"
518
+ )
519
+ try:
520
+ vertex_names = self._create_vertex_types_global(schema.vertex_config)
521
+
522
+ # Initialize edges before creating edge types
523
+ # This sets edge._source and edge._target to dbnames (required for GSQL)
524
+ edges_to_create = list(schema.edge_config.edges_list(include_aux=True))
525
+ for edge in edges_to_create:
526
+ edge.finish_init(schema.vertex_config)
527
+
528
+ # Verify all vertices referenced by edges were created
529
+ created_vertex_set = set(vertex_names)
530
+ for edge in edges_to_create:
531
+ if edge._source not in created_vertex_set:
532
+ raise ValueError(
533
+ f"Edge '{edge.relation}' references source vertex '{edge._source}' "
534
+ f"which was not created. Created vertices: {vertex_names}"
535
+ )
536
+ if edge._target not in created_vertex_set:
537
+ raise ValueError(
538
+ f"Edge '{edge.relation}' references target vertex '{edge._target}' "
539
+ f"which was not created. Created vertices: {vertex_names}"
540
+ )
541
+
542
+ edge_names = self._create_edge_types_global(edges_to_create)
543
+ logger.debug(
544
+ f"Created {len(vertex_names)} vertex types and {len(edge_names)} edge types"
545
+ )
546
+ except Exception as type_error:
547
+ logger.error(
548
+ f"Failed to create vertex/edge types for graph '{graph_name}': {type_error}",
549
+ exc_info=True,
550
+ )
551
+ raise
552
+
553
+ # Step 2: Create graph with vertices and edges explicitly attached
554
+ try:
555
+ if not self.graph_exists(graph_name):
556
+ logger.debug(f"Creating graph '{graph_name}' with types in init_db")
557
+ try:
558
+ self.create_database(
559
+ graph_name,
560
+ vertex_names=vertex_names,
561
+ edge_names=edge_names,
562
+ )
563
+ graph_created = True
564
+ logger.info(f"Successfully created graph '{graph_name}'")
565
+ except Exception as create_error:
566
+ logger.error(
567
+ f"Failed to create graph '{graph_name}': {create_error}",
568
+ exc_info=True,
569
+ )
570
+ raise
571
+ else:
572
+ logger.debug(f"Graph '{graph_name}' already exists in init_db")
573
+ # If graph already exists, associate types via ALTER GRAPH
574
+ try:
575
+ self.define_vertex_collections(schema.vertex_config)
576
+ # Ensure edges are initialized before defining collections
577
+ edges_for_collections = list(
578
+ schema.edge_config.edges_list(include_aux=True)
579
+ )
580
+ for edge in edges_for_collections:
581
+ if edge._source is None or edge._target is None:
582
+ edge.finish_init(schema.vertex_config)
583
+ self.define_edge_collections(edges_for_collections)
584
+ except Exception as define_error:
585
+ logger.warning(
586
+ f"Could not define collections for existing graph '{graph_name}': {define_error}",
587
+ exc_info=True,
588
+ )
589
+ # Continue - graph exists, collections may already be defined
590
+ except Exception as graph_error:
591
+ logger.error(
592
+ f"Error during graph creation/verification for '{graph_name}': {graph_error}",
593
+ exc_info=True,
594
+ )
595
+ raise
596
+
597
+ # Step 3: Define indexes
598
+ try:
599
+ self.define_indexes(schema)
600
+ logger.info(f"Index definition completed for graph '{graph_name}'")
601
+ except Exception as index_error:
602
+ logger.error(
603
+ f"Failed to define indexes for graph '{graph_name}': {index_error}",
604
+ exc_info=True,
605
+ )
606
+ raise
607
+ except Exception as e:
608
+ logger.error(f"Error initializing database: {e}")
609
+ # Graceful teardown: if graph was created in this session, clean it up
610
+ if graph_created:
611
+ try:
612
+ logger.info(
613
+ f"Cleaning up graph '{graph_name}' after initialization failure"
614
+ )
615
+ self.delete_database(graph_name)
616
+ except Exception as cleanup_error:
617
+ logger.warning(
618
+ f"Failed to clean up graph '{graph_name}': {cleanup_error}"
619
+ )
620
+ raise
621
+
622
+ def define_schema(self, schema: Schema):
623
+ """
624
+ Define TigerGraph schema with proper GSQL syntax.
625
+
626
+ Assumes graph already exists (created in init_db). This method:
627
+ 1. Uses the graph from config.database
628
+ 2. Defines vertex types within the graph
629
+ 3. Defines edge types within the graph
630
+ """
631
+ try:
632
+ # Define vertex and edge types within the graph
633
+ # Graph context is ensured by _ensure_graph_context in the called methods
634
+ self.define_vertex_collections(schema.vertex_config)
635
+ # Ensure edges are initialized before defining collections
636
+ edges_for_collections = list(
637
+ schema.edge_config.edges_list(include_aux=True)
638
+ )
639
+ for edge in edges_for_collections:
640
+ if edge._source is None or edge._target is None:
641
+ edge.finish_init(schema.vertex_config)
642
+ self.define_edge_collections(edges_for_collections)
643
+
644
+ except Exception as e:
645
+ logger.error(f"Error defining schema: {e}")
646
+ raise
647
+
648
+ def _format_vertex_fields(self, vertex: Vertex) -> str:
649
+ """
650
+ Format vertex fields for GSQL CREATE VERTEX statement.
651
+
652
+ Uses Field objects with types, applying TigerGraph defaults (STRING for None types).
653
+ Formats fields as: field_name TYPE
654
+
655
+ Args:
656
+ vertex: Vertex object with Field definitions
657
+
658
+ Returns:
659
+ str: Formatted field definitions for GSQL CREATE VERTEX statement
660
+ """
661
+ fields = vertex.fields
662
+
663
+ if not fields:
664
+ # Default fields if none specified
665
+ return 'name STRING DEFAULT "",\n properties MAP<STRING, STRING> DEFAULT (map())'
666
+
667
+ field_list = []
668
+ for field in fields:
669
+ # Field type should already be set (STRING if was None)
670
+ field_type = field.type or FieldType.STRING.value
671
+ # Format as: field_name TYPE
672
+ # TODO: Add DEFAULT clause support if needed in the future
673
+ field_list.append(f"{field.name} {field_type}")
674
+
675
+ return ",\n ".join(field_list)
676
+
677
+ def _format_edge_attributes(self, edge: Edge) -> str:
678
+ """
679
+ Format edge attributes for GSQL CREATE EDGE statement.
680
+
681
+ Edge weights/attributes come from edge.weights.direct (list of Field objects).
682
+ Each weight field needs to be included in the CREATE EDGE statement with its type.
683
+ """
684
+ attrs = []
685
+
686
+ # Get weight fields from edge.weights.direct
687
+ if edge.weights and edge.weights.direct:
688
+ for field in edge.weights.direct:
689
+ # Field objects have name and type attributes
690
+ field_name = field.name
691
+ # Get TigerGraph type - FieldType enum values are already in TigerGraph format
692
+ tg_type = self._get_tigergraph_type(field.type)
693
+ attrs.append(f"{field_name} {tg_type}")
694
+
695
+ return ",\n " + ",\n ".join(attrs) if attrs else ""
696
+
697
+ def _get_tigergraph_type(self, field_type: FieldType | str | None) -> str:
698
+ """
699
+ Convert field type to TigerGraph type string.
700
+
701
+ FieldType enum values are already in TigerGraph format (e.g., "INT", "STRING", "DATETIME").
702
+ This method normalizes various input formats to the correct TigerGraph type.
703
+
704
+ Args:
705
+ field_type: FieldType enum, string, or None
706
+
707
+ Returns:
708
+ str: TigerGraph type string (e.g., "INT", "STRING", "DATETIME")
709
+ """
710
+ if field_type is None:
711
+ return FieldType.STRING.value
712
+
713
+ # If it's a FieldType enum, use its value directly (already in TigerGraph format)
714
+ if isinstance(field_type, FieldType):
715
+ return field_type.value
716
+
717
+ # If it's an enum-like object with a value attribute
718
+ if hasattr(field_type, "value"):
719
+ enum_value = field_type.value
720
+ # Convert to string and normalize
721
+ enum_value_str = str(enum_value).upper()
722
+ # Check if the value matches a FieldType enum value
723
+ if enum_value_str in VALID_TIGERGRAPH_TYPES:
724
+ return enum_value_str
725
+ # Return as string (normalized to uppercase)
726
+ return enum_value_str
727
+
728
+ # If it's a string, normalize and check against FieldType values
729
+ field_type_str = str(field_type).upper()
730
+
731
+ # Check if it matches a FieldType enum value directly
732
+ if field_type_str in VALID_TIGERGRAPH_TYPES:
733
+ return field_type_str
734
+
735
+ # Handle TigerGraph-specific type aliases
736
+ return TIGERGRAPH_TYPE_ALIASES.get(field_type_str, FieldType.STRING.value)
737
+
738
+ def _create_vertex_types_global(self, vertex_config: VertexConfig) -> list[str]:
739
+ """Create TigerGraph vertex types globally (without graph association).
740
+
741
+ Vertices are global in TigerGraph and must be created before they can be
742
+ included in a CREATE GRAPH statement.
743
+
744
+ Creates vertices with PRIMARY_ID (single field) or PRIMARY KEY (composite) syntax.
745
+ For single-field indexes, uses PRIMARY_ID syntax (required by GraphStudio).
746
+ For composite keys, uses PRIMARY KEY syntax (works in GSQL but not GraphStudio).
747
+ According to TigerGraph documentation, fields used in PRIMARY KEY/PRIMARY_ID must be
748
+ defined as regular attributes first, and they remain accessible as attributes.
749
+
750
+ Note: GraphStudio does not support composite keys. Use PRIMARY_ID for single fields
751
+ to ensure compatibility with GraphStudio.
752
+
753
+ Reference: https://docs.tigergraph.com/gsql-ref/4.2/ddl-and-loading/defining-a-graph-schema
754
+
755
+ Args:
756
+ vertex_config: Vertex configuration containing vertices to create
757
+
758
+ Returns:
759
+ list[str]: List of vertex type names that were created (or already existed)
760
+ """
761
+ vertex_names = []
762
+ for vertex in vertex_config.vertices:
763
+ vertex_dbname = vertex_config.vertex_dbname(vertex.name)
764
+ index_fields = vertex_config.index(vertex.name).fields
765
+
766
+ if len(index_fields) == 0:
767
+ raise ValueError(
768
+ f"Vertex '{vertex_dbname}' must have at least one index field"
769
+ )
770
+
771
+ # Get field type for primary key field(s) - convert FieldType enum to string
772
+ field_type_map = {}
773
+ for f in vertex.fields:
774
+ if f.type:
775
+ field_type_map[f.name] = (
776
+ f.type.value if hasattr(f.type, "value") else str(f.type)
777
+ )
778
+ else:
779
+ field_type_map[f.name] = FieldType.STRING.value
780
+
781
+ # Format all fields
782
+ all_fields = []
783
+ for field in vertex.fields:
784
+ if field.type:
785
+ field_type = (
786
+ field.type.value
787
+ if hasattr(field.type, "value")
788
+ else str(field.type)
789
+ )
790
+ else:
791
+ field_type = FieldType.STRING.value
792
+ all_fields.append((field.name, field_type))
793
+
794
+ if len(index_fields) == 1:
795
+ # Single field: use PRIMARY_ID syntax (required by GSQL)
796
+ # Format: PRIMARY_ID field_name field_type, other_field1 TYPE, other_field2 TYPE, ...
797
+ primary_field_name = index_fields[0]
798
+ primary_field_type = field_type_map.get(
799
+ primary_field_name, FieldType.STRING.value
800
+ )
801
+
802
+ other_fields = [
803
+ (name, ftype)
804
+ for name, ftype in all_fields
805
+ if name != primary_field_name
806
+ ]
807
+
808
+ # Build field list: PRIMARY_ID comes first, then other fields
809
+ field_parts = [f"PRIMARY_ID {primary_field_name} {primary_field_type}"]
810
+ field_parts.extend([f"{name} {ftype}" for name, ftype in other_fields])
811
+
812
+ field_definitions = ",\n ".join(field_parts)
813
+ elif len(index_fields) > 1:
814
+ # Composite key: use PRIMARY KEY syntax (works in GSQL but not GraphStudio UI)
815
+ # Format: field1 TYPE, field2 TYPE, ..., PRIMARY KEY (field1, field2, ...)
816
+ logger.warning(
817
+ f"Vertex '{vertex_dbname}' has composite primary key {index_fields}. "
818
+ f"GraphStudio UI does not support composite keys. "
819
+ f"Consider using a single-field PRIMARY_ID instead."
820
+ )
821
+
822
+ # List all fields first
823
+ field_parts = [f"{name} {ftype}" for name, ftype in all_fields]
824
+ # Then add PRIMARY KEY at the end
825
+ vindex = "(" + ", ".join(index_fields) + ")"
826
+ field_parts.append(f"PRIMARY KEY {vindex}")
827
+
828
+ field_definitions = ",\n ".join(field_parts)
829
+ else:
830
+ raise ValueError(
831
+ f"Vertex '{vertex_dbname}' must have at least one index field"
832
+ )
833
+
834
+ # Create the vertex type globally (ignore if exists)
835
+ # Vertices are global in TigerGraph, so no USE GRAPH needed
836
+ # Note: For PRIMARY_ID, the ID field is listed first with PRIMARY_ID keyword
837
+ # For PRIMARY KEY, all fields are listed first, then PRIMARY KEY clause at the end
838
+ # When using PRIMARY_ID, we need primary_id_as_attribute="true" to make the ID
839
+ # accessible as an attribute (required for REST++ API upserts)
840
+ if len(index_fields) == 1:
841
+ # Single field with PRIMARY_ID: enable primary_id_as_attribute so ID is accessible
842
+ create_vertex_cmd = (
843
+ f"CREATE VERTEX {vertex_dbname} (\n"
844
+ f" {field_definitions}\n"
845
+ f') WITH STATS="OUTDEGREE_BY_EDGETYPE", primary_id_as_attribute="true"'
846
+ )
847
+ else:
848
+ # Composite key with PRIMARY KEY: key fields are automatically accessible as attributes
849
+ create_vertex_cmd = (
850
+ f"CREATE VERTEX {vertex_dbname} (\n"
851
+ f" {field_definitions}\n"
852
+ f') WITH STATS="OUTDEGREE_BY_EDGETYPE"'
853
+ )
854
+ logger.debug(f"Executing GSQL: {create_vertex_cmd}")
855
+ try:
856
+ result = self.conn.gsql(create_vertex_cmd)
857
+ logger.debug(f"Result: {result}")
858
+ vertex_names.append(vertex_dbname)
859
+ logger.info(f"Successfully created vertex type '{vertex_dbname}'")
860
+ except Exception as e:
861
+ err = str(e).lower()
862
+ if (
863
+ "used by another object" in err
864
+ or "duplicate" in err
865
+ or "already exists" in err
866
+ ):
867
+ logger.debug(
868
+ f"Vertex type '{vertex_dbname}' already exists; will include in graph"
869
+ )
870
+ vertex_names.append(vertex_dbname)
871
+ else:
872
+ logger.error(
873
+ f"Failed to create vertex type '{vertex_dbname}': {e}\n"
874
+ f"GSQL command was: {create_vertex_cmd}"
875
+ )
876
+ raise
877
+ return vertex_names
878
+
879
+ def define_vertex_collections(self, vertex_config: VertexConfig):
880
+ """Define TigerGraph vertex types and associate them with the current graph.
881
+
882
+ Flow per vertex type:
883
+ 1) Try to CREATE VERTEX (idempotent: ignore "already exists" errors)
884
+ 2) Associate the vertex with the graph via ALTER GRAPH <graph> ADD VERTEX <vertex>
885
+
886
+ Args:
887
+ vertex_config: Vertex configuration containing vertices to create
888
+ """
889
+ # First create all vertex types globally
890
+ vertex_names = self._create_vertex_types_global(vertex_config)
891
+
892
+ # Then associate them with the graph (if graph already exists)
893
+ graph_name = self.config.database
894
+ if graph_name:
895
+ for vertex_name in vertex_names:
896
+ alter_graph_cmd = f"USE GRAPH {graph_name}\nALTER GRAPH {graph_name} ADD VERTEX {vertex_name}"
897
+ logger.debug(f"Executing GSQL: {alter_graph_cmd}")
898
+ try:
899
+ result = self.conn.gsql(alter_graph_cmd)
900
+ logger.debug(f"Result: {result}")
901
+ except Exception as e:
902
+ err = str(e).lower()
903
+ # If already associated, ignore
904
+ if "already" in err and ("added" in err or "exists" in err):
905
+ logger.debug(
906
+ f"Vertex '{vertex_name}' already associated with graph '{graph_name}'"
907
+ )
908
+ else:
909
+ raise
910
+
911
+ def _create_edge_types_global(self, edges: list[Edge]) -> list[str]:
912
+ """Create TigerGraph edge types globally (without graph association).
913
+
914
+ Edges are global in TigerGraph and must be created before they can be
915
+ included in a CREATE GRAPH statement.
916
+
917
+ Args:
918
+ edges: List of edges to create (should have _source_collection and _target_collection populated)
919
+
920
+ Returns:
921
+ list[str]: List of edge type names (relation names) that were created (or already existed)
922
+ """
923
+ edge_names = []
924
+ for edge in edges:
925
+ edge_attrs = self._format_edge_attributes(edge)
926
+
927
+ # Create the edge type globally (ignore if exists/used elsewhere)
928
+ # Edges are global in TigerGraph, so no USE GRAPH needed
929
+ create_edge_cmd = (
930
+ f"CREATE DIRECTED EDGE {edge.relation} (\n"
931
+ f" FROM {edge._source},\n"
932
+ f" TO {edge._target}{edge_attrs}\n"
933
+ f")"
934
+ )
935
+ logger.debug(f"Executing GSQL: {create_edge_cmd}")
936
+ try:
937
+ result = self.conn.gsql(create_edge_cmd)
938
+ logger.debug(f"Result: {result}")
939
+ edge_names.append(edge.relation)
940
+ except Exception as e:
941
+ err = str(e).lower()
942
+ # If the edge name is already used by another object or duplicates exist, continue
943
+ if (
944
+ "used by another object" in err
945
+ or "duplicate" in err
946
+ or "already exists" in err
947
+ ):
948
+ logger.debug(
949
+ f"Edge type '{edge.relation}' already defined; will include in graph"
950
+ )
951
+ edge_names.append(edge.relation)
952
+ else:
953
+ raise
954
+ return edge_names
955
+
956
+ def define_edge_collections(self, edges: list[Edge]):
957
+ """Define TigerGraph edge types and associate them with the current graph.
958
+
959
+ Flow per edge type:
960
+ 1) Try to CREATE DIRECTED EDGE (idempotent: ignore "used by another object"/"duplicate"/"already exists")
961
+ 2) Associate the edge with the graph via ALTER GRAPH <graph> ADD DIRECTED EDGE <edge>
962
+
963
+ Args:
964
+ edges: List of edges to create (should have _source_collection and _target_collection populated)
965
+ """
966
+ # First create all edge types globally
967
+ edge_names = self._create_edge_types_global(edges)
968
+
969
+ # Then associate them with the graph (if graph already exists)
970
+ graph_name = self.config.database
971
+ if graph_name:
972
+ for edge_name in edge_names:
973
+ alter_graph_cmd = (
974
+ f"USE GRAPH {graph_name}\n"
975
+ f"ALTER GRAPH {graph_name} ADD DIRECTED EDGE {edge_name}"
976
+ )
977
+ logger.debug(f"Executing GSQL: {alter_graph_cmd}")
978
+ try:
979
+ result = self.conn.gsql(alter_graph_cmd)
980
+ logger.debug(f"Result: {result}")
981
+ except Exception as e:
982
+ err = str(e).lower()
983
+ # If already associated, ignore
984
+ if "already" in err and ("added" in err or "exists" in err):
985
+ logger.debug(
986
+ f"Edge '{edge_name}' already associated with graph '{graph_name}'"
987
+ )
988
+ else:
989
+ raise
990
+
991
+ def define_vertex_indices(self, vertex_config: VertexConfig):
992
+ """
993
+ TigerGraph automatically indexes primary keys.
994
+ Secondary indices are less common but can be created.
995
+ """
996
+ for vertex_class in vertex_config.vertex_set:
997
+ vertex_dbname = vertex_config.vertex_dbname(vertex_class)
998
+ for index_obj in vertex_config.indexes(vertex_class)[1:]:
999
+ self._add_index(vertex_dbname, index_obj)
1000
+
1001
+ def define_edge_indices(self, edges: list[Edge]):
1002
+ """Define indices for edges if specified."""
1003
+ logger.warning("TigerGraph edge indices not implemented yet [version 4.2.2]")
1004
+
1005
+ def _add_index(self, obj_name, index: Index, is_vertex_index=True):
1006
+ """
1007
+ Create an index on a vertex or edge type using GSQL schema change jobs.
1008
+
1009
+ TigerGraph requires indexes to be created through schema change jobs:
1010
+ 1. CREATE GLOBAL SCHEMA_CHANGE job job_name {ALTER VERTEX ... ADD INDEX ... ON (...);}
1011
+ 2. RUN GLOBAL SCHEMA_CHANGE job job_name
1012
+
1013
+ Note: TigerGraph only supports secondary indexes on a single field.
1014
+ Indexes with multiple fields will be skipped with a warning.
1015
+ Edge indexes are not supported in TigerGraph and will be skipped with a warning.
1016
+
1017
+ Args:
1018
+ obj_name: Name of the vertex type or edge type
1019
+ index: Index configuration object
1020
+ is_vertex_index: Whether this is a vertex index (True) or edge index (False)
1021
+ """
1022
+ try:
1023
+ # TigerGraph doesn't support indexes on edges
1024
+ if not is_vertex_index:
1025
+ logger.warning(
1026
+ f"Edge indexes are not supported in TigerGraph [current version 4.2.2]"
1027
+ f"Skipping index creation for edge '{obj_name}' on field(s) '{index.fields}'"
1028
+ )
1029
+ return
1030
+
1031
+ if not index.fields:
1032
+ logger.warning(f"No fields specified for index on {obj_name}, skipping")
1033
+ return
1034
+
1035
+ # TigerGraph only supports secondary indexes on a single field
1036
+ if len(index.fields) > 1:
1037
+ logger.warning(
1038
+ f"TigerGraph only supports indexes on a single field. "
1039
+ f"Skipping multi-field index on {obj_name} with fields {index.fields}"
1040
+ )
1041
+ return
1042
+
1043
+ # We have exactly one field - proceed with index creation
1044
+ field_name = index.fields[0]
1045
+
1046
+ # Generate index name if not provided
1047
+ if index.name:
1048
+ index_name = index.name
1049
+ else:
1050
+ # Generate name from obj_name and field name
1051
+ index_name = f"{obj_name}_{field_name}_index"
1052
+
1053
+ # Generate job name from obj_name and field name
1054
+ job_name = f"add_{obj_name}_{field_name}_index"
1055
+
1056
+ # Build the ALTER command (single field only)
1057
+ graph_name = self.config.database
1058
+
1059
+ if not graph_name:
1060
+ logger.warning(
1061
+ f"No graph name configured, cannot create index on {obj_name}"
1062
+ )
1063
+ return
1064
+
1065
+ # Build the ALTER statement inside the job (single field in parentheses)
1066
+ # Note: Only vertex indexes are supported - edge indexes are handled earlier
1067
+ alter_stmt = (
1068
+ f"ALTER VERTEX {obj_name} ADD INDEX {index_name} ON ({field_name})"
1069
+ )
1070
+
1071
+ # Step 1: Create the schema change job
1072
+ # only global changes are supported by tigergraph
1073
+ create_job_cmd = (
1074
+ f"USE GLOBAL \n"
1075
+ f"CREATE GLOBAL SCHEMA_CHANGE job {job_name} {{{alter_stmt};}}"
1076
+ )
1077
+
1078
+ logger.debug(f"Executing GSQL (create job): {create_job_cmd}")
1079
+ try:
1080
+ result = self.conn.gsql(create_job_cmd)
1081
+ logger.debug(f"Created schema change job '{job_name}': {result}")
1082
+ except Exception as e:
1083
+ err = str(e).lower()
1084
+ # Check if job already exists
1085
+ if (
1086
+ "already exists" in err
1087
+ or "duplicate" in err
1088
+ or "used by another object" in err
1089
+ ):
1090
+ logger.debug(f"Schema change job '{job_name}' already exists")
1091
+ else:
1092
+ logger.error(
1093
+ f"Failed to create schema change job '{job_name}': {e}"
1094
+ )
1095
+ raise
1096
+
1097
+ # Step 2: Run the schema change job
1098
+ run_job_cmd = f"RUN GLOBAL SCHEMA_CHANGE job {job_name}"
1099
+
1100
+ logger.debug(f"Executing GSQL (run job): {run_job_cmd}")
1101
+ try:
1102
+ result = self.conn.gsql(run_job_cmd)
1103
+ logger.debug(
1104
+ f"Ran schema change job '{job_name}', created index '{index_name}' on {obj_name}: {result}"
1105
+ )
1106
+ except Exception as e:
1107
+ err = str(e).lower()
1108
+ # Check if index already exists or job was already run
1109
+ if (
1110
+ "already exists" in err
1111
+ or "duplicate" in err
1112
+ or "used by another object" in err
1113
+ or "already applied" in err
1114
+ ):
1115
+ logger.debug(
1116
+ f"Index '{index_name}' on {obj_name} already exists or job already run, skipping"
1117
+ )
1118
+ else:
1119
+ logger.error(f"Failed to run schema change job '{job_name}': {e}")
1120
+ raise
1121
+ except Exception as e:
1122
+ logger.warning(f"Could not create index for {obj_name}: {e}")
1123
+
1124
+ def _parse_show_output(self, result_str: str, prefix: str) -> list[str]:
1125
+ """
1126
+ Generic parser for SHOW * output commands.
1127
+
1128
+ Extracts names from lines matching the pattern: "- PREFIX name(...)"
1129
+
1130
+ Args:
1131
+ result_str: String output from SHOW * GSQL command
1132
+ prefix: The prefix to look for (e.g., "VERTEX", "GRAPH", "JOB")
1133
+
1134
+ Returns:
1135
+ List of extracted names
1136
+ """
1137
+ names = []
1138
+ lines = result_str.split("\n")
1139
+
1140
+ for line in lines:
1141
+ line = line.strip()
1142
+ # Skip empty lines and headers
1143
+ if not line or line.startswith("*"):
1144
+ continue
1145
+
1146
+ # Remove leading "- " if present
1147
+ if line.startswith("- "):
1148
+ line = line[2:].strip()
1149
+
1150
+ # Look for prefix pattern
1151
+ prefix_upper = prefix.upper()
1152
+ if line.upper().startswith(f"{prefix_upper} "):
1153
+ # Extract name (after prefix and before opening parenthesis or whitespace)
1154
+ after_prefix = line[len(prefix_upper) + 1 :].strip()
1155
+ # Name is the first word (before space or parenthesis)
1156
+ if "(" in after_prefix:
1157
+ name = after_prefix.split("(")[0].strip()
1158
+ else:
1159
+ # No parenthesis, take the first word
1160
+ name = (
1161
+ after_prefix.split()[0].strip()
1162
+ if after_prefix.split()
1163
+ else None
1164
+ )
1165
+
1166
+ if name:
1167
+ names.append(name)
1168
+
1169
+ return names
1170
+
1171
+ def _parse_show_edge_output(self, result_str: str) -> list[tuple[str, bool]]:
1172
+ """
1173
+ Parse SHOW EDGE * output to extract edge type names and direction.
1174
+
1175
+ Format: "- DIRECTED EDGE belongsTo(FROM Author, TO ResearchField, ...)"
1176
+ or "- UNDIRECTED EDGE edgeName(...)"
1177
+
1178
+ Args:
1179
+ result_str: String output from SHOW EDGE * GSQL command
1180
+
1181
+ Returns:
1182
+ List of tuples (edge_name, is_directed)
1183
+ """
1184
+ edge_types = []
1185
+ lines = result_str.split("\n")
1186
+
1187
+ for line in lines:
1188
+ line = line.strip()
1189
+ # Skip empty lines and headers
1190
+ if not line or line.startswith("*"):
1191
+ continue
1192
+
1193
+ # Remove leading "- " if present
1194
+ if line.startswith("- "):
1195
+ line = line[2:].strip()
1196
+
1197
+ # Look for "DIRECTED EDGE" or "UNDIRECTED EDGE" pattern
1198
+ is_directed = None
1199
+ prefix = None
1200
+ if "DIRECTED EDGE" in line.upper():
1201
+ prefix = "DIRECTED EDGE "
1202
+ is_directed = True
1203
+ elif "UNDIRECTED EDGE" in line.upper():
1204
+ prefix = "UNDIRECTED EDGE "
1205
+ is_directed = False
1206
+
1207
+ if prefix:
1208
+ idx = line.upper().find(prefix)
1209
+ if idx >= 0:
1210
+ after_prefix = line[idx + len(prefix) :].strip()
1211
+ # Extract name before opening parenthesis
1212
+ if "(" in after_prefix:
1213
+ edge_name = after_prefix.split("(")[0].strip()
1214
+ if edge_name:
1215
+ edge_types.append((edge_name, is_directed))
1216
+
1217
+ return edge_types
1218
+
1219
+ def _is_not_found_error(self, error: Exception | str) -> bool:
1220
+ """
1221
+ Check if an error indicates that an object doesn't exist.
1222
+
1223
+ Args:
1224
+ error: Exception object or error string
1225
+
1226
+ Returns:
1227
+ True if the error indicates "not found" or "does not exist"
1228
+ """
1229
+ err_str = str(error).lower()
1230
+ return "does not exist" in err_str or "not found" in err_str
1231
+
1232
+ def _clean_document(self, doc: dict[str, Any]) -> dict[str, Any]:
1233
+ """
1234
+ Remove internal keys that shouldn't be stored in the database.
1235
+
1236
+ Removes keys starting with "_" except "_key".
1237
+
1238
+ Args:
1239
+ doc: Document dictionary to clean
1240
+
1241
+ Returns:
1242
+ Cleaned document dictionary
1243
+ """
1244
+ return {k: v for k, v in doc.items() if not k.startswith("_") or k == "_key"}
1245
+
1246
+ def _parse_show_vertex_output(self, result_str: str) -> list[str]:
1247
+ """Parse SHOW VERTEX * output to extract vertex type names."""
1248
+ return self._parse_show_output(result_str, "VERTEX")
1249
+
1250
+ def _parse_show_graph_output(self, result_str: str) -> list[str]:
1251
+ """Parse SHOW GRAPH * output to extract graph names."""
1252
+ return self._parse_show_output(result_str, "GRAPH")
1253
+
1254
+ def _parse_show_job_output(self, result_str: str) -> list[str]:
1255
+ """Parse SHOW JOB * output to extract job names."""
1256
+ return self._parse_show_output(result_str, "JOB")
1257
+
1258
+ def delete_graph_structure(self, vertex_types=(), graph_names=(), delete_all=False):
1259
+ """
1260
+ Delete graph structure (graphs, vertex types, edge types) from TigerGraph.
1261
+
1262
+ In TigerGraph:
1263
+ - Graph: Top-level container (functions like a database in ArangoDB)
1264
+ - Vertex Types: Global vertex type definitions (can be shared across graphs)
1265
+ - Edge Types: Global edge type definitions (can be shared across graphs)
1266
+ - Vertex and edge types are associated with graphs
1267
+
1268
+ Teardown order:
1269
+ 1. Drop all graphs
1270
+ 2. Drop all edge types globally
1271
+ 3. Drop all vertex types globally
1272
+ 4. Drop all jobs globally
1273
+
1274
+ Args:
1275
+ vertex_types: Vertex type names to delete (not used in TigerGraph teardown)
1276
+ graph_names: Graph names to delete (if empty and delete_all=True, deletes all)
1277
+ delete_all: If True, perform full teardown of all graphs, edges, vertices, and jobs
1278
+ """
1279
+ cnames = vertex_types
1280
+ gnames = graph_names
1281
+ try:
1282
+ if delete_all:
1283
+ # Step 1: Drop all graphs
1284
+ graphs_to_drop = list(gnames) if gnames else []
1285
+
1286
+ # If no specific graphs provided, try to discover and drop all graphs
1287
+ if not graphs_to_drop:
1288
+ try:
1289
+ # Use GSQL to list all graphs
1290
+ show_graphs_cmd = "SHOW GRAPH *"
1291
+ result = self.conn.gsql(show_graphs_cmd)
1292
+ result_str = str(result)
1293
+
1294
+ # Parse graph names using helper method
1295
+ graphs_to_drop = self._parse_show_graph_output(result_str)
1296
+ except Exception as e:
1297
+ logger.debug(f"Could not list graphs: {e}")
1298
+ graphs_to_drop = []
1299
+
1300
+ # Drop each graph
1301
+ logger.info(
1302
+ f"Found {len(graphs_to_drop)} graphs to drop: {graphs_to_drop}"
1303
+ )
1304
+ for graph_name in graphs_to_drop:
1305
+ try:
1306
+ self.delete_database(graph_name)
1307
+ logger.info(f"Successfully dropped graph '{graph_name}'")
1308
+ except Exception as e:
1309
+ if self._is_not_found_error(e):
1310
+ logger.debug(
1311
+ f"Graph '{graph_name}' already dropped or doesn't exist"
1312
+ )
1313
+ else:
1314
+ logger.warning(f"Failed to drop graph '{graph_name}': {e}")
1315
+ logger.warning(
1316
+ f"Error details: {type(e).__name__}: {str(e)}"
1317
+ )
1318
+
1319
+ # Step 2: Drop all edge types globally
1320
+ # Note: Edges must be dropped before vertices due to dependencies
1321
+ # Edges are global, so we need to query them at global level using GSQL
1322
+ try:
1323
+ # Use GSQL to list all global edge types (not graph-scoped)
1324
+ show_edges_cmd = "SHOW EDGE *"
1325
+ result = self.conn.gsql(show_edges_cmd)
1326
+ result_str = str(result)
1327
+
1328
+ # Parse edge types using helper method
1329
+ edge_types = self._parse_show_edge_output(result_str)
1330
+
1331
+ logger.info(
1332
+ f"Found {len(edge_types)} edge types to drop: {[name for name, _ in edge_types]}"
1333
+ )
1334
+ for e_type, is_directed in edge_types:
1335
+ try:
1336
+ # DROP EDGE works for both directed and undirected edges
1337
+ drop_edge_cmd = f"DROP EDGE {e_type}"
1338
+ logger.debug(f"Executing: {drop_edge_cmd}")
1339
+ result = self.conn.gsql(drop_edge_cmd)
1340
+ logger.info(
1341
+ f"Successfully dropped edge type '{e_type}': {result}"
1342
+ )
1343
+ except Exception as e:
1344
+ if self._is_not_found_error(e):
1345
+ logger.debug(
1346
+ f"Edge type '{e_type}' already dropped or doesn't exist"
1347
+ )
1348
+ else:
1349
+ logger.warning(
1350
+ f"Failed to drop edge type '{e_type}': {e}"
1351
+ )
1352
+ logger.warning(
1353
+ f"Error details: {type(e).__name__}: {str(e)}"
1354
+ )
1355
+ except Exception as e:
1356
+ logger.warning(f"Could not list or drop edge types: {e}")
1357
+ logger.warning(f"Error details: {type(e).__name__}: {str(e)}")
1358
+
1359
+ # Step 3: Drop all vertex types globally
1360
+ # Vertices are dropped after edges to avoid dependency issues
1361
+ # Vertices are global, so we need to query them at global level using GSQL
1362
+ try:
1363
+ # Use GSQL to list all global vertex types (not graph-scoped)
1364
+ show_vertices_cmd = "SHOW VERTEX *"
1365
+ result = self.conn.gsql(show_vertices_cmd)
1366
+ result_str = str(result)
1367
+
1368
+ # Parse vertex types using helper method
1369
+ vertex_types = self._parse_show_vertex_output(result_str)
1370
+
1371
+ logger.info(
1372
+ f"Found {len(vertex_types)} vertex types to drop: {vertex_types}"
1373
+ )
1374
+ for v_type in vertex_types:
1375
+ try:
1376
+ # Clear data first to avoid dependency issues
1377
+ try:
1378
+ result = self.conn.delVertices(v_type)
1379
+ logger.debug(
1380
+ f"Cleared data from vertex type '{v_type}': {result}"
1381
+ )
1382
+ except Exception as clear_err:
1383
+ logger.debug(
1384
+ f"Could not clear data from vertex type '{v_type}': {clear_err}"
1385
+ )
1386
+
1387
+ # Drop vertex type
1388
+ drop_vertex_cmd = f"DROP VERTEX {v_type}"
1389
+ logger.debug(f"Executing: {drop_vertex_cmd}")
1390
+ result = self.conn.gsql(drop_vertex_cmd)
1391
+ logger.info(
1392
+ f"Successfully dropped vertex type '{v_type}': {result}"
1393
+ )
1394
+ except Exception as e:
1395
+ if self._is_not_found_error(e):
1396
+ logger.debug(
1397
+ f"Vertex type '{v_type}' already dropped or doesn't exist"
1398
+ )
1399
+ else:
1400
+ logger.warning(
1401
+ f"Failed to drop vertex type '{v_type}': {e}"
1402
+ )
1403
+ logger.warning(
1404
+ f"Error details: {type(e).__name__}: {str(e)}"
1405
+ )
1406
+ except Exception as e:
1407
+ logger.warning(f"Could not list or drop vertex types: {e}")
1408
+ logger.warning(f"Error details: {type(e).__name__}: {str(e)}")
1409
+
1410
+ # Step 4: Drop all jobs globally
1411
+ # Jobs are dropped last since they may reference schema objects
1412
+ try:
1413
+ # Use GSQL to list all global jobs
1414
+ show_jobs_cmd = "SHOW JOB *"
1415
+ result = self.conn.gsql(show_jobs_cmd)
1416
+ result_str = str(result)
1417
+
1418
+ # Parse job names using helper method
1419
+ job_names = self._parse_show_job_output(result_str)
1420
+
1421
+ logger.info(f"Found {len(job_names)} jobs to drop: {job_names}")
1422
+ for job_name in job_names:
1423
+ try:
1424
+ # Drop job
1425
+ # Jobs can be of different types (SCHEMA_CHANGE, LOADING, etc.)
1426
+ # DROP JOB works for all job types
1427
+ drop_job_cmd = f"DROP JOB {job_name}"
1428
+ logger.debug(f"Executing: {drop_job_cmd}")
1429
+ result = self.conn.gsql(drop_job_cmd)
1430
+ logger.info(
1431
+ f"Successfully dropped job '{job_name}': {result}"
1432
+ )
1433
+ except Exception as e:
1434
+ if self._is_not_found_error(e):
1435
+ logger.debug(
1436
+ f"Job '{job_name}' already dropped or doesn't exist"
1437
+ )
1438
+ else:
1439
+ logger.warning(f"Failed to drop job '{job_name}': {e}")
1440
+ logger.warning(
1441
+ f"Error details: {type(e).__name__}: {str(e)}"
1442
+ )
1443
+ except Exception as e:
1444
+ logger.warning(f"Could not list or drop jobs: {e}")
1445
+ logger.warning(f"Error details: {type(e).__name__}: {str(e)}")
1446
+
1447
+ elif gnames:
1448
+ # Drop specific graphs
1449
+ for graph_name in gnames:
1450
+ try:
1451
+ self.delete_database(graph_name)
1452
+ except Exception as e:
1453
+ logger.error(f"Error deleting graph '{graph_name}': {e}")
1454
+ elif cnames:
1455
+ # Delete vertices from specific vertex types (data only, not schema)
1456
+ with self._ensure_graph_context():
1457
+ for class_name in cnames:
1458
+ try:
1459
+ result = self.conn.delVertices(class_name)
1460
+ logger.debug(
1461
+ f"Deleted vertices from {class_name}: {result}"
1462
+ )
1463
+ except Exception as e:
1464
+ logger.error(
1465
+ f"Error deleting vertices from {class_name}: {e}"
1466
+ )
1467
+
1468
+ except Exception as e:
1469
+ logger.error(f"Error in delete_graph_structure: {e}")
1470
+
1471
+ def _generate_upsert_payload(
1472
+ self, data: list[dict[str, Any]], vname: str, vindex: tuple[str, ...]
1473
+ ) -> dict[str, Any]:
1474
+ """
1475
+ Transforms a list of dictionaries into the TigerGraph REST++ batch upsert JSON format.
1476
+
1477
+ The composite Primary ID is created by concatenating the values of the fields
1478
+ specified in vindex with an underscore '_'. Index fields are included in the
1479
+ vertex attributes since PRIMARY KEY fields are automatically accessible as
1480
+ attributes in TigerGraph queries.
1481
+
1482
+ Attribute values are wrapped in {"value": ...} format as required by TigerGraph REST++ API.
1483
+
1484
+ Args:
1485
+ data: List of document dictionaries to upsert
1486
+ vname: Target vertex name
1487
+ vindex: Tuple of index fields used to create the composite Primary ID
1488
+
1489
+ Returns:
1490
+ Dictionary in TigerGraph REST++ batch upsert format:
1491
+ {"vertices": {vname: {vertex_id: {attr_name: {"value": attr_value}, ...}}}}
1492
+ """
1493
+ # Initialize the required JSON structure for vertices
1494
+ payload: dict[str, Any] = {"vertices": {vname: {}}}
1495
+ vertex_map = payload["vertices"][vname]
1496
+
1497
+ for record in data:
1498
+ try:
1499
+ # 1. Calculate the Composite Primary ID
1500
+ # Assumes all index keys exist in the record
1501
+ primary_id_components = [str(record[key]) for key in vindex]
1502
+ vertex_id = "_".join(primary_id_components)
1503
+
1504
+ # 2. Clean the record (remove internal keys that shouldn't be stored)
1505
+ clean_record = self._clean_document(record)
1506
+
1507
+ # 3. Keep index fields in attributes
1508
+ # When using PRIMARY KEY (composite keys), the key fields are automatically
1509
+ # accessible as attributes in queries, so we include them in the payload
1510
+
1511
+ # 4. Format attributes for TigerGraph REST++ API
1512
+ # TigerGraph requires attribute values to be wrapped in {"value": ...}
1513
+ formatted_attributes = {
1514
+ k: {"value": v} for k, v in clean_record.items()
1515
+ }
1516
+
1517
+ # 5. Add the record attributes to the map using the composite ID as the key
1518
+ vertex_map[vertex_id] = formatted_attributes
1519
+
1520
+ except KeyError as e:
1521
+ logger.warning(
1522
+ f"Record is missing a required index field: {e}. Skipping record: {record}"
1523
+ )
1524
+ continue
1525
+
1526
+ return payload
1527
+
1528
+ def _upsert_data(
1529
+ self,
1530
+ payload: dict[str, Any],
1531
+ host: str,
1532
+ graph_name: str,
1533
+ username: str | None = None,
1534
+ password: str | None = None,
1535
+ ) -> dict[str, Any]:
1536
+ """
1537
+ Sends the generated JSON payload to the TigerGraph REST++ upsert endpoint.
1538
+
1539
+ Args:
1540
+ payload: The JSON payload in TigerGraph REST++ format
1541
+ host: Base host URL (e.g., "http://localhost:9000")
1542
+ graph_name: Name of the graph
1543
+ username: Optional username for authentication
1544
+ password: Optional password for authentication
1545
+
1546
+ Returns:
1547
+ Dictionary containing the response from TigerGraph
1548
+ """
1549
+ url = f"{host}/graph/{graph_name}"
1550
+
1551
+ headers = {
1552
+ "Content-Type": "application/json",
1553
+ }
1554
+
1555
+ logger.debug(f"Attempting batch upsert to: {url}")
1556
+
1557
+ try:
1558
+ # Use HTTP Basic Auth if username and password are provided
1559
+ auth = None
1560
+ if username and password:
1561
+ auth = (username, password)
1562
+
1563
+ response = requests.post(
1564
+ url,
1565
+ headers=headers,
1566
+ data=json.dumps(payload, default=_json_serializer),
1567
+ auth=auth,
1568
+ # Increase timeout for large batches
1569
+ timeout=120,
1570
+ )
1571
+ response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
1572
+
1573
+ # TigerGraph response is a JSON object
1574
+ return response.json()
1575
+
1576
+ except requests_exceptions.HTTPError as errh:
1577
+ logger.error(f"HTTP Error: {errh}")
1578
+ error_details = ""
1579
+ try:
1580
+ error_details = response.text
1581
+ except Exception:
1582
+ pass
1583
+ return {"error": True, "message": str(errh), "details": error_details}
1584
+ except requests_exceptions.ConnectionError as errc:
1585
+ logger.error(f"Error Connecting: {errc}")
1586
+ return {"error": True, "message": str(errc)}
1587
+ except requests_exceptions.Timeout as errt:
1588
+ logger.error(f"Timeout Error: {errt}")
1589
+ return {"error": True, "message": str(errt)}
1590
+ except requests_exceptions.RequestException as err:
1591
+ logger.error(f"An unexpected error occurred: {err}")
1592
+ return {"error": True, "message": str(err)}
1593
+
1594
+ def upsert_docs_batch(self, docs, class_name, match_keys, **kwargs):
1595
+ """
1596
+ Batch upsert documents as vertices using TigerGraph REST++ API.
1597
+
1598
+ Creates a GSQL job and formats the payload for batch upsert operations.
1599
+ Uses composite Primary IDs constructed from match_keys.
1600
+ """
1601
+ dry = kwargs.pop("dry", False)
1602
+ if dry:
1603
+ logger.debug(f"Dry run: would upsert {len(docs)} documents to {class_name}")
1604
+ return
1605
+
1606
+ try:
1607
+ # Convert match_keys to tuple if it's a list
1608
+ vindex = tuple(match_keys) if isinstance(match_keys, list) else match_keys
1609
+
1610
+ # Generate the upsert payload
1611
+ payload = self._generate_upsert_payload(docs, class_name, vindex)
1612
+
1613
+ # Check if payload has any vertices
1614
+ if not payload.get("vertices", {}).get(class_name):
1615
+ logger.warning(f"No valid vertices to upsert for {class_name}")
1616
+ return
1617
+
1618
+ # Build REST++ endpoint URL
1619
+ host = f"{self.config.url_without_port}:{self.config.port}"
1620
+ graph_name = self.config.database
1621
+ if not graph_name:
1622
+ raise ValueError("Graph name (database) must be configured")
1623
+
1624
+ # Send the upsert request with username/password authentication
1625
+ result = self._upsert_data(
1626
+ payload,
1627
+ host,
1628
+ graph_name,
1629
+ username=self.config.username,
1630
+ password=self.config.password,
1631
+ )
1632
+
1633
+ if result.get("error"):
1634
+ logger.error(
1635
+ f"Error upserting vertices to {class_name}: {result.get('message')}"
1636
+ )
1637
+ # Fallback to individual operations
1638
+ self._fallback_individual_upsert(docs, class_name, match_keys)
1639
+ else:
1640
+ num_vertices = len(payload["vertices"][class_name])
1641
+ logger.debug(
1642
+ f"Upserted {num_vertices} vertices to {class_name}: {result}"
1643
+ )
1644
+ return result
1645
+
1646
+ except Exception as e:
1647
+ logger.error(f"Error upserting vertices to {class_name}: {e}")
1648
+ # Fallback to individual operations
1649
+ self._fallback_individual_upsert(docs, class_name, match_keys)
1650
+
1651
+ def _fallback_individual_upsert(self, docs, class_name, match_keys):
1652
+ """Fallback method for individual vertex upserts."""
1653
+ for doc in docs:
1654
+ try:
1655
+ vertex_id = self._extract_id(doc, match_keys)
1656
+ if vertex_id:
1657
+ clean_doc = self._clean_document(doc)
1658
+ # Serialize datetime objects before passing to pyTigerGraph
1659
+ # pyTigerGraph's upsertVertex expects JSON-serializable data
1660
+ serialized_doc = json.loads(
1661
+ json.dumps(clean_doc, default=_json_serializer)
1662
+ )
1663
+ self.conn.upsertVertex(class_name, vertex_id, serialized_doc)
1664
+ except Exception as e:
1665
+ logger.error(f"Error upserting individual vertex {vertex_id}: {e}")
1666
+
1667
+ def _generate_edge_upsert_payload(
1668
+ self,
1669
+ edges_data: list[tuple[dict, dict, dict]],
1670
+ source_class: str,
1671
+ target_class: str,
1672
+ edge_type: str,
1673
+ match_keys_source: tuple[str, ...],
1674
+ match_keys_target: tuple[str, ...],
1675
+ ) -> dict[str, Any]:
1676
+ """
1677
+ Transforms edge data into the TigerGraph REST++ batch upsert JSON format.
1678
+
1679
+ Args:
1680
+ edges_data: List of tuples (source_doc, target_doc, edge_props)
1681
+ source_class: Source vertex type name
1682
+ target_class: Target vertex type name
1683
+ edge_type: Edge type/relation name
1684
+ match_keys_source: Tuple of index fields for source vertex
1685
+ match_keys_target: Tuple of index fields for target vertex
1686
+
1687
+ Returns:
1688
+ Dictionary in TigerGraph REST++ batch upsert format for edges
1689
+ """
1690
+ # Initialize the required JSON structure for edges
1691
+ payload: dict[str, Any] = {"edges": {source_class: {}}}
1692
+ source_map = payload["edges"][source_class]
1693
+
1694
+ for source_doc, target_doc, edge_props in edges_data:
1695
+ try:
1696
+ # Extract source ID (composite if needed)
1697
+ if isinstance(match_keys_source, tuple) and len(match_keys_source) > 1:
1698
+ source_id_components = [
1699
+ str(source_doc[key]) for key in match_keys_source
1700
+ ]
1701
+ source_id = "_".join(source_id_components)
1702
+ else:
1703
+ source_id = self._extract_id(source_doc, match_keys_source)
1704
+
1705
+ # Extract target ID (composite if needed)
1706
+ if isinstance(match_keys_target, tuple) and len(match_keys_target) > 1:
1707
+ target_id_components = [
1708
+ str(target_doc[key]) for key in match_keys_target
1709
+ ]
1710
+ target_id = "_".join(target_id_components)
1711
+ else:
1712
+ target_id = self._extract_id(target_doc, match_keys_target)
1713
+
1714
+ if not source_id or not target_id:
1715
+ logger.warning(
1716
+ f"Missing source_id ({source_id}) or target_id ({target_id}) for edge"
1717
+ )
1718
+ continue
1719
+
1720
+ # Initialize source vertex entry if not exists
1721
+ if source_id not in source_map:
1722
+ source_map[source_id] = {edge_type: {}}
1723
+
1724
+ # Initialize edge type entry if not exists
1725
+ if edge_type not in source_map[source_id]:
1726
+ source_map[source_id][edge_type] = {}
1727
+
1728
+ # Initialize target vertex type entry if not exists
1729
+ if target_class not in source_map[source_id][edge_type]:
1730
+ source_map[source_id][edge_type][target_class] = {}
1731
+
1732
+ # Format edge attributes for TigerGraph REST++ API
1733
+ # Clean edge properties (remove internal keys)
1734
+ clean_edge_props = self._clean_document(edge_props)
1735
+
1736
+ # Format attributes with {"value": ...} wrapper
1737
+ formatted_attributes = {
1738
+ k: {"value": v} for k, v in clean_edge_props.items()
1739
+ }
1740
+
1741
+ # Add target vertex with edge attributes under target vertex type
1742
+ source_map[source_id][edge_type][target_class][target_id] = (
1743
+ formatted_attributes
1744
+ )
1745
+
1746
+ except KeyError as e:
1747
+ logger.warning(
1748
+ f"Edge is missing a required field: {e}. Skipping edge: {source_doc}, {target_doc}"
1749
+ )
1750
+ continue
1751
+ except Exception as e:
1752
+ logger.error(f"Error processing edge: {e}")
1753
+ continue
1754
+
1755
+ return payload
1756
+
1757
+ def insert_edges_batch(
1758
+ self,
1759
+ docs_edges,
1760
+ source_class,
1761
+ target_class,
1762
+ relation_name,
1763
+ collection_name=None,
1764
+ match_keys_source=("_key",),
1765
+ match_keys_target=("_key",),
1766
+ filter_uniques=True,
1767
+ uniq_weight_fields=None,
1768
+ uniq_weight_collections=None,
1769
+ upsert_option=False,
1770
+ head=None,
1771
+ **kwargs,
1772
+ ):
1773
+ """
1774
+ Batch insert/upsert edges using TigerGraph REST++ API.
1775
+
1776
+ Handles edge data in tuple format: [(source_doc, target_doc, edge_props), ...]
1777
+ or dict format: [{"_source_aux": {...}, "_target_aux": {...}, "_edge_props": {...}}, ...]
1778
+
1779
+ Args:
1780
+ docs_edges: List of edge documents (tuples or dicts)
1781
+ source_class: Source vertex type name
1782
+ target_class: Target vertex type name
1783
+ relation_name: Edge type/relation name
1784
+ collection_name: Alternative edge collection name (used if relation_name is None)
1785
+ match_keys_source: Keys to match source vertices
1786
+ match_keys_target: Keys to match target vertices
1787
+ filter_uniques: If True, filter duplicate edges
1788
+ uniq_weight_fields: Fields to consider for uniqueness (not used in TigerGraph)
1789
+ uniq_weight_collections: Collections to consider for uniqueness (not used in TigerGraph)
1790
+ upsert_option: If True, use upsert (default behavior in TigerGraph)
1791
+ head: Optional limit on number of edges to insert
1792
+ **kwargs: Additional options:
1793
+ - dry: If True, don't execute the query
1794
+ """
1795
+ dry = kwargs.pop("dry", False)
1796
+ if dry:
1797
+ logger.debug(f"Dry run: would insert {len(docs_edges)} edges")
1798
+ return
1799
+
1800
+ # Process edges list
1801
+ if isinstance(docs_edges, list):
1802
+ if head is not None:
1803
+ docs_edges = docs_edges[:head]
1804
+ if filter_uniques:
1805
+ docs_edges = pick_unique_dict(docs_edges)
1806
+
1807
+ # Normalize edge data format - handle both tuple and dict formats
1808
+ normalized_edges = []
1809
+ for edge_item in docs_edges:
1810
+ try:
1811
+ if isinstance(edge_item, tuple) and len(edge_item) == 3:
1812
+ # Tuple format: (source_doc, target_doc, edge_props)
1813
+ source_doc, target_doc, edge_props = edge_item
1814
+ normalized_edges.append((source_doc, target_doc, edge_props))
1815
+ elif isinstance(edge_item, dict):
1816
+ # Dict format: {"_source_aux": {...}, "_target_aux": {...}, "_edge_props": {...}}
1817
+ source_doc = edge_item.get("_source_aux", {})
1818
+ target_doc = edge_item.get("_target_aux", {})
1819
+ edge_props = edge_item.get("_edge_props", {})
1820
+ normalized_edges.append((source_doc, target_doc, edge_props))
1821
+ else:
1822
+ logger.warning(f"Unexpected edge format: {edge_item}")
1823
+ except Exception as e:
1824
+ logger.error(f"Error normalizing edge item: {e}")
1825
+ continue
1826
+
1827
+ if not normalized_edges:
1828
+ logger.warning("No valid edges to insert")
1829
+ return
1830
+
1831
+ try:
1832
+ # Convert match_keys to tuples if they're lists
1833
+ match_keys_src = (
1834
+ tuple(match_keys_source)
1835
+ if isinstance(match_keys_source, list)
1836
+ else match_keys_source
1837
+ )
1838
+ match_keys_tgt = (
1839
+ tuple(match_keys_target)
1840
+ if isinstance(match_keys_target, list)
1841
+ else match_keys_target
1842
+ )
1843
+
1844
+ edge_type = relation_name or collection_name
1845
+ if not edge_type:
1846
+ logger.error(
1847
+ "Edge type must be specified via relation_name or collection_name"
1848
+ )
1849
+ return
1850
+
1851
+ # Generate the edge upsert payload
1852
+ payload = self._generate_edge_upsert_payload(
1853
+ normalized_edges,
1854
+ source_class,
1855
+ target_class,
1856
+ edge_type,
1857
+ match_keys_src,
1858
+ match_keys_tgt,
1859
+ )
1860
+
1861
+ # Check if payload has any edges
1862
+ source_vertices = payload.get("edges", {}).get(source_class, {})
1863
+ if not source_vertices:
1864
+ logger.warning(f"No valid edges to upsert for edge type {edge_type}")
1865
+ return
1866
+
1867
+ # Build REST++ endpoint URL
1868
+ host = f"{self.config.url_without_port}:{self.config.port}"
1869
+ graph_name = self.config.database
1870
+ if not graph_name:
1871
+ raise ValueError("Graph name (database) must be configured")
1872
+
1873
+ # Send the upsert request with username/password authentication
1874
+ result = self._upsert_data(
1875
+ payload,
1876
+ host,
1877
+ graph_name,
1878
+ username=self.config.username,
1879
+ password=self.config.password,
1880
+ )
1881
+
1882
+ if result.get("error"):
1883
+ logger.error(
1884
+ f"Error upserting edges of type {edge_type}: {result.get('message')}"
1885
+ )
1886
+ else:
1887
+ # Count edges in payload
1888
+ edge_count = 0
1889
+ for source_edges in source_vertices.values():
1890
+ if edge_type in source_edges:
1891
+ if target_class in source_edges[edge_type]:
1892
+ edge_count += len(source_edges[edge_type][target_class])
1893
+ logger.debug(
1894
+ f"Upserted {edge_count} edges of type {edge_type}: {result}"
1895
+ )
1896
+ return result
1897
+
1898
+ except Exception as e:
1899
+ logger.error(f"Error batch inserting edges: {e}")
1900
+
1901
+ def _extract_id(self, doc, match_keys):
1902
+ """
1903
+ Extract vertex ID from document based on match keys.
1904
+ """
1905
+ if not doc:
1906
+ return None
1907
+
1908
+ # Try _key first (common in ArangoDB style docs)
1909
+ if "_key" in doc and doc["_key"]:
1910
+ return str(doc["_key"])
1911
+
1912
+ # Try other match keys
1913
+ for key in match_keys:
1914
+ if key in doc and doc[key] is not None:
1915
+ return str(doc[key])
1916
+
1917
+ # Fallback: create composite ID
1918
+ id_parts = []
1919
+ for key in match_keys:
1920
+ if key in doc and doc[key] is not None:
1921
+ id_parts.append(str(doc[key]))
1922
+
1923
+ return "_".join(id_parts) if id_parts else None
1924
+
1925
+ def insert_return_batch(self, docs, class_name):
1926
+ """
1927
+ TigerGraph doesn't have INSERT...RETURN semantics like ArangoDB.
1928
+ """
1929
+ raise NotImplementedError(
1930
+ "insert_return_batch not supported in TigerGraph - use upsert_docs_batch instead"
1931
+ )
1932
+
1933
+ def _render_rest_filter(
1934
+ self,
1935
+ filters: list | dict | Clause | None,
1936
+ field_types: dict[str, FieldType] | None = None,
1937
+ ) -> str:
1938
+ """Convert filter expressions to REST++ filter format.
1939
+
1940
+ REST++ filter format: "field=value" or "field>value" etc.
1941
+ Format: fieldoperatorvalue (no spaces, quotes for string values)
1942
+ Example: "hindex=10" or "hindex>20" or 'name="John"'
1943
+
1944
+ Args:
1945
+ filters: Filter expression to convert
1946
+ field_types: Optional mapping of field names to FieldType enum values
1947
+
1948
+ Returns:
1949
+ str: REST++ filter string (empty if no filters)
1950
+ """
1951
+ if filters is not None:
1952
+ if not isinstance(filters, Clause):
1953
+ ff = Expression.from_dict(filters)
1954
+ else:
1955
+ ff = filters
1956
+
1957
+ # Use ExpressionFlavor.TIGERGRAPH with empty doc_name to trigger REST++ format
1958
+ # Pass field_types to help with proper value quoting
1959
+ filter_str = ff(
1960
+ doc_name="",
1961
+ kind=ExpressionFlavor.TIGERGRAPH,
1962
+ field_types=field_types,
1963
+ )
1964
+ return filter_str
1965
+ else:
1966
+ return ""
1967
+
1968
+ def fetch_docs(
1969
+ self,
1970
+ class_name,
1971
+ filters: list | dict | Clause | None = None,
1972
+ limit: int | None = None,
1973
+ return_keys: list | None = None,
1974
+ unset_keys: list | None = None,
1975
+ **kwargs,
1976
+ ):
1977
+ """
1978
+ Fetch documents (vertices) with filtering and projection using REST++ API.
1979
+
1980
+ Args:
1981
+ class_name: Vertex type name (or dbname)
1982
+ filters: Filter expression (list, dict, or Clause)
1983
+ limit: Maximum number of documents to return
1984
+ return_keys: Keys to return (projection)
1985
+ unset_keys: Keys to exclude (projection)
1986
+ **kwargs: Additional parameters
1987
+ field_types: Optional mapping of field names to FieldType enum values
1988
+ Used to properly quote string values in filters
1989
+ If not provided and vertex_config is provided, will be auto-detected
1990
+ vertex_config: Optional VertexConfig object to use for field type lookup
1991
+
1992
+ Returns:
1993
+ list: List of fetched documents
1994
+ """
1995
+ try:
1996
+ graph_name = self.config.database
1997
+ if not graph_name:
1998
+ raise ValueError("Graph name (database) must be configured")
1999
+
2000
+ # Get field_types from kwargs or auto-detect from vertex_config
2001
+ field_types = kwargs.get("field_types")
2002
+ vertex_config = kwargs.get("vertex_config")
2003
+
2004
+ if field_types is None and vertex_config is not None:
2005
+ field_types = {f.name: f.type for f in vertex_config.fields(class_name)}
2006
+
2007
+ # Build REST++ filter string with field type information
2008
+ filter_str = self._render_rest_filter(filters, field_types=field_types)
2009
+
2010
+ # Build REST++ API endpoint with query parameters manually
2011
+ # Format: /graph/{graph_name}/vertices/{vertex_type}?filter=...&limit=...
2012
+ # Example: /graph/g22c97325/vertices/Author?filter=hindex>20&limit=10
2013
+
2014
+ endpoint = f"/graph/{graph_name}/vertices/{class_name}"
2015
+ query_parts = []
2016
+
2017
+ if filter_str:
2018
+ # URL-encode the filter string to handle special characters
2019
+ encoded_filter = quote(filter_str, safe="=<>!&|")
2020
+ query_parts.append(f"filter={encoded_filter}")
2021
+ if limit is not None:
2022
+ query_parts.append(f"limit={limit}")
2023
+
2024
+ if query_parts:
2025
+ endpoint = f"{endpoint}?{'&'.join(query_parts)}"
2026
+
2027
+ logger.debug(f"Calling REST++ API: {endpoint}")
2028
+
2029
+ # Call REST++ API directly (no params dict, we built the URL ourselves)
2030
+ response = self._call_restpp_api(endpoint)
2031
+
2032
+ # Parse REST++ response (vertices only)
2033
+ result: list[dict[str, Any]] = self._parse_restpp_response(
2034
+ response, is_edge=False
2035
+ )
2036
+
2037
+ # Check for errors
2038
+ if isinstance(response, dict) and response.get("error"):
2039
+ raise Exception(
2040
+ f"REST++ API error: {response.get('message', response)}"
2041
+ )
2042
+
2043
+ # Apply projection (client-side projection is acceptable for result formatting)
2044
+ if return_keys is not None:
2045
+ result = [
2046
+ {k: doc.get(k) for k in return_keys if k in doc}
2047
+ for doc in result
2048
+ if isinstance(doc, dict)
2049
+ ]
2050
+ elif unset_keys is not None:
2051
+ result = [
2052
+ {k: v for k, v in doc.items() if k not in unset_keys}
2053
+ for doc in result
2054
+ if isinstance(doc, dict)
2055
+ ]
2056
+
2057
+ return result
2058
+
2059
+ except Exception as e:
2060
+ logger.error(f"Error fetching documents from {class_name} via REST++: {e}")
2061
+ raise
2062
+
2063
+ def fetch_edges(
2064
+ self,
2065
+ from_type: str,
2066
+ from_id: str,
2067
+ edge_type: str | None = None,
2068
+ to_type: str | None = None,
2069
+ to_id: str | None = None,
2070
+ filters: list | dict | Clause | None = None,
2071
+ limit: int | None = None,
2072
+ return_keys: list | None = None,
2073
+ unset_keys: list | None = None,
2074
+ **kwargs,
2075
+ ):
2076
+ """
2077
+ Fetch edges from TigerGraph using pyTigerGraph's getEdges method.
2078
+
2079
+ In TigerGraph, you must know at least one vertex ID before you can fetch edges.
2080
+ Uses pyTigerGraph's getEdges method which handles special characters in vertex IDs.
2081
+
2082
+ Args:
2083
+ from_type: Source vertex type (required)
2084
+ from_id: Source vertex ID (required)
2085
+ edge_type: Optional edge type to filter by
2086
+ to_type: Optional target vertex type to filter by (not used in pyTigerGraph)
2087
+ to_id: Optional target vertex ID to filter by (not used in pyTigerGraph)
2088
+ filters: Additional query filters (not supported by pyTigerGraph getEdges)
2089
+ limit: Maximum number of edges to return (not supported by pyTigerGraph getEdges)
2090
+ return_keys: Keys to return (projection)
2091
+ unset_keys: Keys to exclude (projection)
2092
+ **kwargs: Additional parameters
2093
+
2094
+ Returns:
2095
+ list: List of fetched edges
2096
+ """
2097
+ try:
2098
+ if not from_type or not from_id:
2099
+ raise ValueError(
2100
+ "from_type and from_id are required for fetching edges in TigerGraph"
2101
+ )
2102
+
2103
+ # Use pyTigerGraph's getEdges method
2104
+ # Signature: getEdges(sourceVertexType, sourceVertexId, edgeType=None)
2105
+ # Returns: list of edge dictionaries
2106
+ logger.debug(
2107
+ f"Fetching edges using pyTigerGraph: from_type={from_type}, from_id={from_id}, edge_type={edge_type}"
2108
+ )
2109
+
2110
+ # Handle None edge_type by passing empty string (default behavior)
2111
+ edge_type_str = edge_type if edge_type is not None else ""
2112
+ edges = self.conn.getEdges(from_type, from_id, edge_type_str, fmt="py")
2113
+
2114
+ # Parse pyTigerGraph response format
2115
+ # getEdges returns list of dicts with format like:
2116
+ # [{"e_type": "...", "from": {...}, "to": {...}, "attributes": {...}}, ...]
2117
+ # Type annotation: result is list[dict[str, Any]]
2118
+ # getEdges can return dict, str, or DataFrame, but with fmt="py" it returns dict
2119
+ if isinstance(edges, list):
2120
+ # Type narrowing: after isinstance check, we know it's a list
2121
+ # Use cast to help type checker understand the elements are dicts
2122
+ result = cast(list[dict[str, Any]], edges)
2123
+ elif isinstance(edges, dict):
2124
+ # If it's a single dict, wrap it in a list
2125
+ result = [cast(dict[str, Any], edges)]
2126
+ else:
2127
+ # Fallback for unexpected types
2128
+ result: list[dict[str, Any]] = []
2129
+
2130
+ # Apply limit if specified (client-side since pyTigerGraph doesn't support it)
2131
+ if limit is not None and limit > 0:
2132
+ result = result[:limit]
2133
+
2134
+ # Apply projection (client-side projection is acceptable for result formatting)
2135
+ if return_keys is not None:
2136
+ result = [
2137
+ {k: doc.get(k) for k in return_keys if k in doc}
2138
+ for doc in result
2139
+ if isinstance(doc, dict)
2140
+ ]
2141
+ elif unset_keys is not None:
2142
+ result = [
2143
+ {k: v for k, v in doc.items() if k not in unset_keys}
2144
+ for doc in result
2145
+ if isinstance(doc, dict)
2146
+ ]
2147
+
2148
+ return result
2149
+
2150
+ except Exception as e:
2151
+ logger.error(f"Error fetching edges via pyTigerGraph: {e}")
2152
+ raise
2153
+
2154
+ def _parse_restpp_response(
2155
+ self, response: dict | list, is_edge: bool = False
2156
+ ) -> list[dict]:
2157
+ """Parse REST++ API response into list of documents.
2158
+
2159
+ Args:
2160
+ response: REST++ API response (dict or list)
2161
+ is_edge: Whether this is an edge response (default: False for vertices)
2162
+
2163
+ Returns:
2164
+ list: List of parsed documents
2165
+ """
2166
+ result = []
2167
+ if isinstance(response, dict):
2168
+ if "results" in response:
2169
+ for data in response["results"]:
2170
+ if is_edge:
2171
+ # Edge response format: {"e_type": "...", "from_id": "...", "to_id": "...", "attributes": {...}}
2172
+ edge_type = data.get("e_type", "")
2173
+ from_id = data.get("from_id", data.get("from", ""))
2174
+ to_id = data.get("to_id", data.get("to", ""))
2175
+ attributes = data.get("attributes", {})
2176
+ doc = {
2177
+ **attributes,
2178
+ "edge_type": edge_type,
2179
+ "from_id": from_id,
2180
+ "to_id": to_id,
2181
+ }
2182
+ else:
2183
+ # Vertex response format: {"v_id": "...", "attributes": {...}}
2184
+ vertex_id = data.get("v_id", data.get("id"))
2185
+ attributes = data.get("attributes", {})
2186
+ doc = {**attributes, "id": vertex_id}
2187
+ result.append(doc)
2188
+ elif isinstance(response, list):
2189
+ # Direct list response
2190
+ for data in response:
2191
+ if isinstance(data, dict):
2192
+ if is_edge:
2193
+ edge_type = data.get("e_type", "")
2194
+ from_id = data.get("from_id", data.get("from", ""))
2195
+ to_id = data.get("to_id", data.get("to", ""))
2196
+ attributes = data.get("attributes", data)
2197
+ doc = {
2198
+ **attributes,
2199
+ "edge_type": edge_type,
2200
+ "from_id": from_id,
2201
+ "to_id": to_id,
2202
+ }
2203
+ else:
2204
+ vertex_id = data.get("v_id", data.get("id"))
2205
+ attributes = data.get("attributes", data)
2206
+ doc = {**attributes, "id": vertex_id}
2207
+ result.append(doc)
2208
+ return result
2209
+
2210
+ def fetch_present_documents(
2211
+ self,
2212
+ batch,
2213
+ class_name,
2214
+ match_keys,
2215
+ keep_keys,
2216
+ flatten=False,
2217
+ filters: list | dict | None = None,
2218
+ ):
2219
+ """
2220
+ Check which documents from batch are present in the database.
2221
+ """
2222
+ try:
2223
+ present_docs = {}
2224
+
2225
+ for i, doc in enumerate(batch):
2226
+ vertex_id = self._extract_id(doc, match_keys)
2227
+ if not vertex_id:
2228
+ continue
2229
+
2230
+ try:
2231
+ vertex_data = self.conn.getVerticesById(class_name, vertex_id)
2232
+ if vertex_data and vertex_id in vertex_data:
2233
+ # Extract requested keys
2234
+ vertex_attrs = vertex_data[vertex_id].get("attributes", {})
2235
+ filtered_doc = {}
2236
+
2237
+ for key in keep_keys:
2238
+ if key == "id":
2239
+ filtered_doc[key] = vertex_id
2240
+ elif key in vertex_attrs:
2241
+ filtered_doc[key] = vertex_attrs[key]
2242
+
2243
+ present_docs[i] = [filtered_doc]
2244
+
2245
+ except Exception:
2246
+ # Vertex doesn't exist or error occurred
2247
+ continue
2248
+
2249
+ return present_docs
2250
+
2251
+ except Exception as e:
2252
+ logger.error(f"Error fetching present documents: {e}")
2253
+ return {}
2254
+
2255
+ def aggregate(
2256
+ self,
2257
+ class_name,
2258
+ aggregation_function: AggregationType,
2259
+ discriminant: str | None = None,
2260
+ aggregated_field: str | None = None,
2261
+ filters: list | dict | None = None,
2262
+ ):
2263
+ """
2264
+ Perform aggregation operations.
2265
+ """
2266
+ try:
2267
+ if aggregation_function == AggregationType.COUNT and discriminant is None:
2268
+ # Simple vertex count
2269
+ count = self.conn.getVertexCount(class_name)
2270
+ return [{"_value": count}]
2271
+ else:
2272
+ # Complex aggregations require custom GSQL queries
2273
+ logger.warning(
2274
+ f"Complex aggregation {aggregation_function} requires custom GSQL implementation"
2275
+ )
2276
+ return []
2277
+ except Exception as e:
2278
+ logger.error(f"Error in aggregation: {e}")
2279
+ return []
2280
+
2281
+ def keep_absent_documents(
2282
+ self,
2283
+ batch,
2284
+ class_name,
2285
+ match_keys,
2286
+ keep_keys,
2287
+ filters: list | dict | None = None,
2288
+ ):
2289
+ """
2290
+ Return documents from batch that are NOT present in database.
2291
+ """
2292
+ present_docs_indices = self.fetch_present_documents(
2293
+ batch=batch,
2294
+ class_name=class_name,
2295
+ match_keys=match_keys,
2296
+ keep_keys=keep_keys,
2297
+ flatten=False,
2298
+ filters=filters,
2299
+ )
2300
+
2301
+ absent_indices = sorted(
2302
+ set(range(len(batch))) - set(present_docs_indices.keys())
2303
+ )
2304
+ return [batch[i] for i in absent_indices]
2305
+
2306
+ def define_indexes(self, schema: Schema):
2307
+ """Define all indexes from schema."""
2308
+ try:
2309
+ self.define_vertex_indices(schema.vertex_config)
2310
+ # Ensure edges are initialized before defining indices
2311
+ edges_for_indices = list(schema.edge_config.edges_list(include_aux=True))
2312
+ for edge in edges_for_indices:
2313
+ if edge._source is None or edge._target is None:
2314
+ edge.finish_init(schema.vertex_config)
2315
+ self.define_edge_indices(edges_for_indices)
2316
+ except Exception as e:
2317
+ logger.error(f"Error defining indexes: {e}")
2318
+
2319
+ def fetch_indexes(self, vertex_type: str | None = None):
2320
+ """
2321
+ Fetch indexes for vertex types using GSQL.
2322
+
2323
+ In TigerGraph, indexes are associated with vertex types.
2324
+ Use DESCRIBE VERTEX to get index information.
2325
+
2326
+ Args:
2327
+ vertex_type: Optional vertex type name to fetch indexes for.
2328
+ If None, fetches indexes for all vertex types.
2329
+
2330
+ Returns:
2331
+ dict: Mapping of vertex type names to their indexes.
2332
+ Format: {vertex_type: [{"name": "index_name", "fields": ["field1", ...]}, ...]}
2333
+ """
2334
+ try:
2335
+ with self._ensure_graph_context():
2336
+ result = {}
2337
+
2338
+ if vertex_type:
2339
+ vertex_types = [vertex_type]
2340
+ else:
2341
+ vertex_types = self.conn.getVertexTypes(force=True)
2342
+
2343
+ for v_type in vertex_types:
2344
+ try:
2345
+ # Parse indexes from the describe output
2346
+ indexes = []
2347
+ try:
2348
+ indexes.append(
2349
+ {"name": "stat_index", "source": "show_stat"}
2350
+ )
2351
+ except Exception:
2352
+ # If SHOW STAT INDEX doesn't work, try alternative methods
2353
+ pass
2354
+
2355
+ result[v_type] = indexes
2356
+ except Exception as e:
2357
+ logger.debug(
2358
+ f"Could not fetch indexes for vertex type {v_type}: {e}"
2359
+ )
2360
+ result[v_type] = []
2361
+
2362
+ return result
2363
+ except Exception as e:
2364
+ logger.error(f"Error fetching indexes: {e}")
2365
+ return {}