omnibase_infra 0.2.3__py3-none-any.whl → 0.2.5__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.
- omnibase_infra/handlers/handler_graph.py +860 -4
- omnibase_infra/nodes/node_intent_storage_effect/__init__.py +50 -0
- omnibase_infra/nodes/node_intent_storage_effect/contract.yaml +194 -0
- omnibase_infra/nodes/node_intent_storage_effect/models/__init__.py +24 -0
- omnibase_infra/nodes/node_intent_storage_effect/models/model_intent_storage_input.py +141 -0
- omnibase_infra/nodes/node_intent_storage_effect/models/model_intent_storage_output.py +130 -0
- omnibase_infra/nodes/node_intent_storage_effect/node.py +94 -0
- omnibase_infra/nodes/node_intent_storage_effect/registry/__init__.py +35 -0
- omnibase_infra/nodes/node_intent_storage_effect/registry/registry_infra_intent_storage.py +294 -0
- omnibase_infra/validation/__init__.py +16 -0
- {omnibase_infra-0.2.3.dist-info → omnibase_infra-0.2.5.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.3.dist-info → omnibase_infra-0.2.5.dist-info}/RECORD +15 -7
- {omnibase_infra-0.2.3.dist-info → omnibase_infra-0.2.5.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.3.dist-info → omnibase_infra-0.2.5.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.3.dist-info → omnibase_infra-0.2.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -33,6 +33,7 @@ Circuit Breaker Pattern:
|
|
|
33
33
|
from __future__ import annotations
|
|
34
34
|
|
|
35
35
|
import logging
|
|
36
|
+
import re
|
|
36
37
|
import time
|
|
37
38
|
from collections.abc import Mapping
|
|
38
39
|
from uuid import UUID, uuid4
|
|
@@ -47,6 +48,7 @@ from neo4j.exceptions import (
|
|
|
47
48
|
)
|
|
48
49
|
|
|
49
50
|
from omnibase_core.container import ModelONEXContainer
|
|
51
|
+
from omnibase_core.models.dispatch import ModelHandlerOutput
|
|
50
52
|
from omnibase_core.models.graph import (
|
|
51
53
|
ModelGraphBatchResult,
|
|
52
54
|
ModelGraphDatabaseNode,
|
|
@@ -61,20 +63,42 @@ from omnibase_core.models.graph import (
|
|
|
61
63
|
ModelGraphTraversalResult,
|
|
62
64
|
)
|
|
63
65
|
from omnibase_core.types import JsonType
|
|
64
|
-
from omnibase_infra.enums import EnumInfraTransportType
|
|
66
|
+
from omnibase_infra.enums import EnumInfraTransportType, EnumResponseStatus
|
|
65
67
|
from omnibase_infra.errors import (
|
|
66
68
|
InfraAuthenticationError,
|
|
67
69
|
InfraConnectionError,
|
|
68
70
|
ModelInfraErrorContext,
|
|
69
71
|
RuntimeHostError,
|
|
70
72
|
)
|
|
71
|
-
from omnibase_infra.
|
|
73
|
+
from omnibase_infra.handlers.models.graph import (
|
|
74
|
+
ModelGraphExecutePayload,
|
|
75
|
+
ModelGraphHandlerPayload,
|
|
76
|
+
ModelGraphQueryPayload,
|
|
77
|
+
ModelGraphRecord,
|
|
78
|
+
)
|
|
79
|
+
from omnibase_infra.handlers.models.model_graph_handler_response import (
|
|
80
|
+
ModelGraphHandlerResponse,
|
|
81
|
+
)
|
|
82
|
+
from omnibase_infra.mixins import MixinAsyncCircuitBreaker, MixinEnvelopeExtraction
|
|
72
83
|
from omnibase_infra.utils.util_env_parsing import parse_env_float
|
|
73
84
|
from omnibase_spi.protocols.storage import ProtocolGraphDatabaseHandler
|
|
74
85
|
|
|
75
86
|
logger = logging.getLogger(__name__)
|
|
76
87
|
|
|
77
88
|
HANDLER_ID_GRAPH: str = "graph-handler"
|
|
89
|
+
|
|
90
|
+
SUPPORTED_OPERATIONS: frozenset[str] = frozenset(
|
|
91
|
+
{
|
|
92
|
+
"graph.execute_query",
|
|
93
|
+
"graph.execute_query_batch",
|
|
94
|
+
"graph.create_node",
|
|
95
|
+
"graph.create_relationship",
|
|
96
|
+
"graph.delete_node",
|
|
97
|
+
"graph.delete_relationship",
|
|
98
|
+
"graph.traverse",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
78
102
|
_DEFAULT_TIMEOUT_SECONDS: float = parse_env_float(
|
|
79
103
|
"ONEX_GRAPH_TIMEOUT",
|
|
80
104
|
30.0,
|
|
@@ -86,8 +110,14 @@ _DEFAULT_TIMEOUT_SECONDS: float = parse_env_float(
|
|
|
86
110
|
_DEFAULT_POOL_SIZE: int = 50
|
|
87
111
|
_HEALTH_CACHE_SECONDS: float = 10.0
|
|
88
112
|
|
|
113
|
+
# Cypher label validation: alphanumeric and underscore only
|
|
114
|
+
# This prevents injection attacks via malicious label values
|
|
115
|
+
_CYPHER_LABEL_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
89
116
|
|
|
90
|
-
|
|
117
|
+
|
|
118
|
+
class HandlerGraph(
|
|
119
|
+
MixinAsyncCircuitBreaker, MixinEnvelopeExtraction, ProtocolGraphDatabaseHandler
|
|
120
|
+
):
|
|
91
121
|
"""Graph database handler implementing ProtocolGraphDatabaseHandler.
|
|
92
122
|
|
|
93
123
|
Provides typed graph database operations using neo4j async driver,
|
|
@@ -536,6 +566,36 @@ class HandlerGraph(MixinAsyncCircuitBreaker, ProtocolGraphDatabaseHandler):
|
|
|
536
566
|
f"Batch execution failed: {type(e).__name__}", context=ctx
|
|
537
567
|
) from e
|
|
538
568
|
|
|
569
|
+
def _validate_cypher_labels(
|
|
570
|
+
self, labels: list[str], operation: str, correlation_id: UUID
|
|
571
|
+
) -> None:
|
|
572
|
+
"""Validate that all labels are safe for Cypher queries.
|
|
573
|
+
|
|
574
|
+
Labels are embedded directly in Cypher queries (not parameterized),
|
|
575
|
+
so they must be validated to prevent injection attacks.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
labels: List of labels to validate.
|
|
579
|
+
operation: Operation name for error context.
|
|
580
|
+
correlation_id: Correlation ID for error context.
|
|
581
|
+
|
|
582
|
+
Raises:
|
|
583
|
+
RuntimeHostError: If any label contains unsafe characters.
|
|
584
|
+
"""
|
|
585
|
+
for label in labels:
|
|
586
|
+
if not _CYPHER_LABEL_PATTERN.match(label):
|
|
587
|
+
ctx = ModelInfraErrorContext.with_correlation(
|
|
588
|
+
transport_type=EnumInfraTransportType.GRAPH,
|
|
589
|
+
operation=operation,
|
|
590
|
+
target_name="graph_handler",
|
|
591
|
+
correlation_id=correlation_id,
|
|
592
|
+
)
|
|
593
|
+
raise RuntimeHostError(
|
|
594
|
+
f"Invalid label '{label}': labels must start with a letter or "
|
|
595
|
+
f"underscore and contain only alphanumeric characters and underscores",
|
|
596
|
+
context=ctx,
|
|
597
|
+
)
|
|
598
|
+
|
|
539
599
|
async def create_node(
|
|
540
600
|
self,
|
|
541
601
|
labels: list[str],
|
|
@@ -561,6 +621,9 @@ class HandlerGraph(MixinAsyncCircuitBreaker, ProtocolGraphDatabaseHandler):
|
|
|
561
621
|
async with self._circuit_breaker_lock:
|
|
562
622
|
await self._check_circuit_breaker("create_node", correlation_id)
|
|
563
623
|
|
|
624
|
+
# Validate labels to prevent Cypher injection
|
|
625
|
+
self._validate_cypher_labels(labels, "create_node", correlation_id)
|
|
626
|
+
|
|
564
627
|
# Build Cypher query with labels
|
|
565
628
|
labels_str = ":".join(labels) if labels else ""
|
|
566
629
|
label_clause = f":{labels_str}" if labels_str else ""
|
|
@@ -645,6 +708,20 @@ class HandlerGraph(MixinAsyncCircuitBreaker, ProtocolGraphDatabaseHandler):
|
|
|
645
708
|
async with self._circuit_breaker_lock:
|
|
646
709
|
await self._check_circuit_breaker("create_relationship", correlation_id)
|
|
647
710
|
|
|
711
|
+
# Validate relationship type to prevent Cypher injection
|
|
712
|
+
if not _CYPHER_LABEL_PATTERN.match(relationship_type):
|
|
713
|
+
ctx = ModelInfraErrorContext.with_correlation(
|
|
714
|
+
transport_type=EnumInfraTransportType.GRAPH,
|
|
715
|
+
operation="create_relationship",
|
|
716
|
+
target_name="graph_handler",
|
|
717
|
+
correlation_id=correlation_id,
|
|
718
|
+
)
|
|
719
|
+
raise RuntimeHostError(
|
|
720
|
+
f"Invalid relationship_type '{relationship_type}': must start with a "
|
|
721
|
+
f"letter or underscore and contain only alphanumeric characters and underscores",
|
|
722
|
+
context=ctx,
|
|
723
|
+
)
|
|
724
|
+
|
|
648
725
|
# Determine if IDs are element IDs (strings with colons) or internal IDs
|
|
649
726
|
from_is_element_id = isinstance(from_node_id, str) and ":" in from_node_id
|
|
650
727
|
to_is_element_id = isinstance(to_node_id, str) and ":" in to_node_id
|
|
@@ -925,6 +1002,29 @@ class HandlerGraph(MixinAsyncCircuitBreaker, ProtocolGraphDatabaseHandler):
|
|
|
925
1002
|
is_element_id = isinstance(start_node_id, str) and ":" in str(start_node_id)
|
|
926
1003
|
start_time = time.perf_counter()
|
|
927
1004
|
|
|
1005
|
+
# Validate relationship types to prevent Cypher injection
|
|
1006
|
+
if relationship_types:
|
|
1007
|
+
for rel_type in relationship_types:
|
|
1008
|
+
if not _CYPHER_LABEL_PATTERN.match(rel_type):
|
|
1009
|
+
ctx = ModelInfraErrorContext.with_correlation(
|
|
1010
|
+
transport_type=EnumInfraTransportType.GRAPH,
|
|
1011
|
+
operation="traverse",
|
|
1012
|
+
target_name="graph_handler",
|
|
1013
|
+
correlation_id=correlation_id,
|
|
1014
|
+
)
|
|
1015
|
+
raise RuntimeHostError(
|
|
1016
|
+
f"Invalid relationship_type '{rel_type}': must start with a "
|
|
1017
|
+
f"letter or underscore and contain only alphanumeric characters "
|
|
1018
|
+
f"and underscores",
|
|
1019
|
+
context=ctx,
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
# Validate filter labels to prevent Cypher injection
|
|
1023
|
+
if filters and filters.node_labels:
|
|
1024
|
+
self._validate_cypher_labels(
|
|
1025
|
+
filters.node_labels, "traverse", correlation_id
|
|
1026
|
+
)
|
|
1027
|
+
|
|
928
1028
|
# Build match clause for start node
|
|
929
1029
|
if is_element_id:
|
|
930
1030
|
start_match = "MATCH (start) WHERE elementId(start) = $start_id"
|
|
@@ -1155,5 +1255,761 @@ class HandlerGraph(MixinAsyncCircuitBreaker, ProtocolGraphDatabaseHandler):
|
|
|
1155
1255
|
supports_transactions=self.supports_transactions,
|
|
1156
1256
|
)
|
|
1157
1257
|
|
|
1258
|
+
async def execute(
|
|
1259
|
+
self, envelope: dict[str, object]
|
|
1260
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1261
|
+
"""Execute graph operation from envelope.
|
|
1262
|
+
|
|
1263
|
+
Dispatches to specialized methods based on operation field.
|
|
1264
|
+
This method enables contract-based handler discovery via HandlerPluginLoader.
|
|
1265
|
+
|
|
1266
|
+
Supported operations:
|
|
1267
|
+
- graph.execute_query: Execute a Cypher query
|
|
1268
|
+
- graph.execute_query_batch: Execute multiple queries in transaction
|
|
1269
|
+
- graph.create_node: Create a node with labels and properties
|
|
1270
|
+
- graph.create_relationship: Create relationship between nodes
|
|
1271
|
+
- graph.delete_node: Delete a node (optionally with DETACH)
|
|
1272
|
+
- graph.delete_relationship: Delete a relationship
|
|
1273
|
+
- graph.traverse: Traverse graph from starting node
|
|
1274
|
+
|
|
1275
|
+
Args:
|
|
1276
|
+
envelope: Request envelope containing:
|
|
1277
|
+
- operation: Graph operation (graph.execute_query, etc.)
|
|
1278
|
+
- payload: dict with operation-specific parameters
|
|
1279
|
+
- correlation_id: Optional correlation ID for tracing
|
|
1280
|
+
- envelope_id: Optional envelope ID for causality tracking
|
|
1281
|
+
|
|
1282
|
+
Returns:
|
|
1283
|
+
ModelHandlerOutput wrapping the operation result with correlation tracking.
|
|
1284
|
+
|
|
1285
|
+
Raises:
|
|
1286
|
+
RuntimeHostError: If handler not initialized or invalid input.
|
|
1287
|
+
InfraConnectionError: If graph database connection fails.
|
|
1288
|
+
InfraAuthenticationError: If authentication fails.
|
|
1289
|
+
|
|
1290
|
+
Envelope-Based Routing:
|
|
1291
|
+
This handler uses envelope-based operation routing. See CLAUDE.md section
|
|
1292
|
+
"Intent Model Architecture > Envelope-Based Handler Routing" for the full
|
|
1293
|
+
design pattern and how orchestrators translate intents to handler envelopes.
|
|
1294
|
+
"""
|
|
1295
|
+
correlation_id = self._extract_correlation_id(envelope)
|
|
1296
|
+
input_envelope_id = self._extract_envelope_id(envelope)
|
|
1297
|
+
|
|
1298
|
+
if not self._initialized or self._driver is None:
|
|
1299
|
+
raise RuntimeHostError(
|
|
1300
|
+
"HandlerGraph not initialized. Call initialize() first.",
|
|
1301
|
+
context=self._error_context("execute", correlation_id),
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
operation = envelope.get("operation")
|
|
1305
|
+
if not isinstance(operation, str):
|
|
1306
|
+
raise RuntimeHostError(
|
|
1307
|
+
"Missing or invalid 'operation' in envelope",
|
|
1308
|
+
context=self._error_context("execute", correlation_id),
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
payload = envelope.get("payload")
|
|
1312
|
+
if not isinstance(payload, dict):
|
|
1313
|
+
raise RuntimeHostError(
|
|
1314
|
+
"Missing or invalid 'payload' in envelope",
|
|
1315
|
+
context=self._error_context(operation, correlation_id),
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
# Dispatch table maps operation strings to handler methods
|
|
1319
|
+
dispatch_table = {
|
|
1320
|
+
"graph.execute_query": self._execute_query_operation,
|
|
1321
|
+
"graph.execute_query_batch": self._execute_query_batch_operation,
|
|
1322
|
+
"graph.create_node": self._create_node_operation,
|
|
1323
|
+
"graph.create_relationship": self._create_relationship_operation,
|
|
1324
|
+
"graph.delete_node": self._delete_node_operation,
|
|
1325
|
+
"graph.delete_relationship": self._delete_relationship_operation,
|
|
1326
|
+
"graph.traverse": self._traverse_operation,
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
handler = dispatch_table.get(operation)
|
|
1330
|
+
if handler is None:
|
|
1331
|
+
raise RuntimeHostError(
|
|
1332
|
+
f"Operation '{operation}' not supported. "
|
|
1333
|
+
f"Available: {', '.join(sorted(dispatch_table.keys()))}",
|
|
1334
|
+
context=self._error_context(operation, correlation_id),
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
return await handler(payload, correlation_id, input_envelope_id)
|
|
1338
|
+
|
|
1339
|
+
async def _execute_query_operation(
|
|
1340
|
+
self,
|
|
1341
|
+
payload: dict[str, object],
|
|
1342
|
+
correlation_id: UUID,
|
|
1343
|
+
input_envelope_id: UUID,
|
|
1344
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1345
|
+
"""Execute graph.execute_query operation.
|
|
1346
|
+
|
|
1347
|
+
Validates that payload contains required 'query' field and optional
|
|
1348
|
+
'parameters' dict, then delegates to execute_query() and converts
|
|
1349
|
+
the result to ModelHandlerOutput format.
|
|
1350
|
+
|
|
1351
|
+
Args:
|
|
1352
|
+
payload: Request payload with 'query' and optional 'parameters'.
|
|
1353
|
+
correlation_id: Correlation ID for tracing.
|
|
1354
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1355
|
+
|
|
1356
|
+
Returns:
|
|
1357
|
+
ModelHandlerOutput wrapping query results.
|
|
1358
|
+
|
|
1359
|
+
Raises:
|
|
1360
|
+
RuntimeHostError: If query field missing or parameters invalid.
|
|
1361
|
+
"""
|
|
1362
|
+
query = payload.get("query")
|
|
1363
|
+
if not isinstance(query, str):
|
|
1364
|
+
raise RuntimeHostError(
|
|
1365
|
+
"Missing or invalid 'query' in payload",
|
|
1366
|
+
context=self._error_context("graph.execute_query", correlation_id),
|
|
1367
|
+
)
|
|
1368
|
+
|
|
1369
|
+
parameters = payload.get("parameters")
|
|
1370
|
+
params_dict: Mapping[str, JsonType] | None = None
|
|
1371
|
+
if parameters is not None:
|
|
1372
|
+
if not isinstance(parameters, dict):
|
|
1373
|
+
raise RuntimeHostError(
|
|
1374
|
+
"Invalid 'parameters' in payload - must be a dict",
|
|
1375
|
+
context=self._error_context("graph.execute_query", correlation_id),
|
|
1376
|
+
)
|
|
1377
|
+
# Type ignore: dict variance - dict[str, object] to Mapping[str, JsonType]
|
|
1378
|
+
params_dict = parameters # type: ignore[assignment]
|
|
1379
|
+
|
|
1380
|
+
try:
|
|
1381
|
+
result = await self.execute_query(query, params_dict)
|
|
1382
|
+
except (InfraConnectionError, InfraAuthenticationError, RuntimeHostError):
|
|
1383
|
+
# Already has proper context, re-raise as-is
|
|
1384
|
+
raise
|
|
1385
|
+
except Exception as e:
|
|
1386
|
+
raise RuntimeHostError(
|
|
1387
|
+
f"Query execution failed: {e}",
|
|
1388
|
+
context=self._error_context("graph.execute_query", correlation_id),
|
|
1389
|
+
) from e
|
|
1390
|
+
|
|
1391
|
+
# Convert records to ModelGraphRecord format
|
|
1392
|
+
# Note: Type ignore needed due to dict variance - dict[str, JsonType] vs dict[str, object]
|
|
1393
|
+
records = [
|
|
1394
|
+
ModelGraphRecord(data=record) # type: ignore[arg-type]
|
|
1395
|
+
for record in result.records
|
|
1396
|
+
]
|
|
1397
|
+
|
|
1398
|
+
query_payload = ModelGraphQueryPayload(
|
|
1399
|
+
cypher=query,
|
|
1400
|
+
records=records,
|
|
1401
|
+
summary={
|
|
1402
|
+
"query_type": result.summary.query_type,
|
|
1403
|
+
"database": result.summary.database,
|
|
1404
|
+
"contains_updates": result.summary.contains_updates,
|
|
1405
|
+
"execution_time_ms": result.execution_time_ms,
|
|
1406
|
+
"nodes_created": result.counters.nodes_created,
|
|
1407
|
+
"nodes_deleted": result.counters.nodes_deleted,
|
|
1408
|
+
"relationships_created": result.counters.relationships_created,
|
|
1409
|
+
"relationships_deleted": result.counters.relationships_deleted,
|
|
1410
|
+
},
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
return self._build_graph_response(
|
|
1414
|
+
query_payload, correlation_id, input_envelope_id
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
async def _execute_query_batch_operation(
|
|
1418
|
+
self,
|
|
1419
|
+
payload: dict[str, object],
|
|
1420
|
+
correlation_id: UUID,
|
|
1421
|
+
input_envelope_id: UUID,
|
|
1422
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1423
|
+
"""Execute graph.execute_query_batch operation.
|
|
1424
|
+
|
|
1425
|
+
Validates batch query structure with fail-fast semantics:
|
|
1426
|
+
- 'queries' must be a list of dicts
|
|
1427
|
+
- Each query dict must have 'query' (str) and optional 'parameters' (dict)
|
|
1428
|
+
- 'transaction' must be boolean if provided
|
|
1429
|
+
|
|
1430
|
+
Args:
|
|
1431
|
+
payload: Request payload with 'queries' list and optional 'transaction'.
|
|
1432
|
+
correlation_id: Correlation ID for tracing.
|
|
1433
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1434
|
+
|
|
1435
|
+
Returns:
|
|
1436
|
+
ModelHandlerOutput wrapping batch execution results.
|
|
1437
|
+
|
|
1438
|
+
Raises:
|
|
1439
|
+
RuntimeHostError: If queries list invalid or any query malformed.
|
|
1440
|
+
"""
|
|
1441
|
+
queries_raw = payload.get("queries")
|
|
1442
|
+
if not isinstance(queries_raw, list):
|
|
1443
|
+
raise RuntimeHostError(
|
|
1444
|
+
"Missing or invalid 'queries' in payload - must be a list",
|
|
1445
|
+
context=self._error_context(
|
|
1446
|
+
"graph.execute_query_batch", correlation_id
|
|
1447
|
+
),
|
|
1448
|
+
)
|
|
1449
|
+
|
|
1450
|
+
# Convert to list of tuples with fail-fast validation
|
|
1451
|
+
queries: list[tuple[str, Mapping[str, JsonType] | None]] = []
|
|
1452
|
+
for idx, q in enumerate(queries_raw):
|
|
1453
|
+
if not isinstance(q, dict):
|
|
1454
|
+
raise RuntimeHostError(
|
|
1455
|
+
f"Query at index {idx} must be a dict, got {type(q).__name__}",
|
|
1456
|
+
context=self._error_context(
|
|
1457
|
+
"graph.execute_query_batch", correlation_id
|
|
1458
|
+
),
|
|
1459
|
+
)
|
|
1460
|
+
query_str = q.get("query")
|
|
1461
|
+
if not isinstance(query_str, str):
|
|
1462
|
+
raise RuntimeHostError(
|
|
1463
|
+
f"Query at index {idx} missing or invalid 'query' field",
|
|
1464
|
+
context=self._error_context(
|
|
1465
|
+
"graph.execute_query_batch", correlation_id
|
|
1466
|
+
),
|
|
1467
|
+
)
|
|
1468
|
+
params = q.get("parameters")
|
|
1469
|
+
if params is not None and not isinstance(params, dict):
|
|
1470
|
+
raise RuntimeHostError(
|
|
1471
|
+
f"Query at index {idx} has invalid 'parameters' - must be dict or null",
|
|
1472
|
+
context=self._error_context(
|
|
1473
|
+
"graph.execute_query_batch", correlation_id
|
|
1474
|
+
),
|
|
1475
|
+
)
|
|
1476
|
+
# Type ignore: dict variance - dict[str, object] to Mapping[str, JsonType]
|
|
1477
|
+
queries.append(
|
|
1478
|
+
(query_str, params if isinstance(params, dict) else None) # type: ignore[arg-type]
|
|
1479
|
+
)
|
|
1480
|
+
|
|
1481
|
+
# Validate transaction is boolean - don't silently coerce other types
|
|
1482
|
+
transaction_raw = payload.get("transaction", True)
|
|
1483
|
+
if not isinstance(transaction_raw, bool):
|
|
1484
|
+
raise RuntimeHostError(
|
|
1485
|
+
f"Invalid 'transaction' in payload - must be boolean, "
|
|
1486
|
+
f"got {type(transaction_raw).__name__}",
|
|
1487
|
+
context=self._error_context(
|
|
1488
|
+
"graph.execute_query_batch", correlation_id
|
|
1489
|
+
),
|
|
1490
|
+
)
|
|
1491
|
+
transaction = transaction_raw
|
|
1492
|
+
|
|
1493
|
+
try:
|
|
1494
|
+
result = await self.execute_query_batch(queries, transaction=transaction)
|
|
1495
|
+
except (InfraConnectionError, InfraAuthenticationError, RuntimeHostError):
|
|
1496
|
+
# Already has proper context, re-raise as-is
|
|
1497
|
+
raise
|
|
1498
|
+
except Exception as e:
|
|
1499
|
+
raise RuntimeHostError(
|
|
1500
|
+
f"Batch query execution failed: {e}",
|
|
1501
|
+
context=self._error_context(
|
|
1502
|
+
"graph.execute_query_batch", correlation_id
|
|
1503
|
+
),
|
|
1504
|
+
) from e
|
|
1505
|
+
|
|
1506
|
+
execute_payload = ModelGraphExecutePayload(
|
|
1507
|
+
cypher="BATCH",
|
|
1508
|
+
counters={
|
|
1509
|
+
"success": result.success,
|
|
1510
|
+
"rollback_occurred": result.rollback_occurred,
|
|
1511
|
+
"query_count": len(result.results),
|
|
1512
|
+
"transaction_id": str(result.transaction_id)
|
|
1513
|
+
if result.transaction_id
|
|
1514
|
+
else None,
|
|
1515
|
+
},
|
|
1516
|
+
success=result.success,
|
|
1517
|
+
)
|
|
1518
|
+
|
|
1519
|
+
return self._build_graph_response(
|
|
1520
|
+
execute_payload, correlation_id, input_envelope_id
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
async def _create_node_operation(
|
|
1524
|
+
self,
|
|
1525
|
+
payload: dict[str, object],
|
|
1526
|
+
correlation_id: UUID,
|
|
1527
|
+
input_envelope_id: UUID,
|
|
1528
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1529
|
+
"""Execute graph.create_node operation.
|
|
1530
|
+
|
|
1531
|
+
Validates optional 'labels' (list of strings) and 'properties' (dict),
|
|
1532
|
+
then delegates to create_node() method.
|
|
1533
|
+
|
|
1534
|
+
Args:
|
|
1535
|
+
payload: Request payload with optional 'labels' and 'properties'.
|
|
1536
|
+
correlation_id: Correlation ID for tracing.
|
|
1537
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
ModelHandlerOutput wrapping created node details.
|
|
1541
|
+
|
|
1542
|
+
Raises:
|
|
1543
|
+
RuntimeHostError: If labels or properties have invalid types.
|
|
1544
|
+
"""
|
|
1545
|
+
labels_raw = payload.get("labels")
|
|
1546
|
+
if labels_raw is not None:
|
|
1547
|
+
if not isinstance(labels_raw, list):
|
|
1548
|
+
raise RuntimeHostError(
|
|
1549
|
+
"Invalid 'labels' in payload - must be a list of strings",
|
|
1550
|
+
context=self._error_context("graph.create_node", correlation_id),
|
|
1551
|
+
)
|
|
1552
|
+
labels_list: list[str] = [str(lbl) for lbl in labels_raw]
|
|
1553
|
+
else:
|
|
1554
|
+
labels_list = []
|
|
1555
|
+
|
|
1556
|
+
properties_raw = payload.get("properties")
|
|
1557
|
+
if properties_raw is not None:
|
|
1558
|
+
if not isinstance(properties_raw, dict):
|
|
1559
|
+
raise RuntimeHostError(
|
|
1560
|
+
"Invalid 'properties' in payload - must be a dict",
|
|
1561
|
+
context=self._error_context("graph.create_node", correlation_id),
|
|
1562
|
+
)
|
|
1563
|
+
# Type ignore: dict variance - dict[str, object] to Mapping[str, JsonType]
|
|
1564
|
+
props_dict: Mapping[str, JsonType] = properties_raw # type: ignore[assignment]
|
|
1565
|
+
else:
|
|
1566
|
+
props_dict = {}
|
|
1567
|
+
|
|
1568
|
+
try:
|
|
1569
|
+
result = await self.create_node(labels_list, props_dict)
|
|
1570
|
+
except (InfraConnectionError, InfraAuthenticationError, RuntimeHostError):
|
|
1571
|
+
# Already has proper context, re-raise as-is
|
|
1572
|
+
raise
|
|
1573
|
+
except Exception as e:
|
|
1574
|
+
raise RuntimeHostError(
|
|
1575
|
+
f"Node creation failed: {e}",
|
|
1576
|
+
context=self._error_context("graph.create_node", correlation_id),
|
|
1577
|
+
) from e
|
|
1578
|
+
|
|
1579
|
+
labels_str = ":" + ":".join(labels_list) if labels_list else "n"
|
|
1580
|
+
execute_payload = ModelGraphExecutePayload(
|
|
1581
|
+
cypher=f"CREATE ({labels_str} ...)",
|
|
1582
|
+
counters={
|
|
1583
|
+
"nodes_created": 1,
|
|
1584
|
+
"node_id": result.id,
|
|
1585
|
+
"element_id": result.element_id,
|
|
1586
|
+
"labels": result.labels,
|
|
1587
|
+
"properties": result.properties,
|
|
1588
|
+
},
|
|
1589
|
+
success=True,
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
return self._build_graph_response(
|
|
1593
|
+
execute_payload, correlation_id, input_envelope_id
|
|
1594
|
+
)
|
|
1595
|
+
|
|
1596
|
+
async def _create_relationship_operation(
|
|
1597
|
+
self,
|
|
1598
|
+
payload: dict[str, object],
|
|
1599
|
+
correlation_id: UUID,
|
|
1600
|
+
input_envelope_id: UUID,
|
|
1601
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1602
|
+
"""Execute graph.create_relationship operation.
|
|
1603
|
+
|
|
1604
|
+
Validates required fields 'from_node_id', 'to_node_id', 'relationship_type'
|
|
1605
|
+
and optional 'properties' dict, then delegates to create_relationship().
|
|
1606
|
+
|
|
1607
|
+
Args:
|
|
1608
|
+
payload: Request payload with node IDs, relationship type, and properties.
|
|
1609
|
+
correlation_id: Correlation ID for tracing.
|
|
1610
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1611
|
+
|
|
1612
|
+
Returns:
|
|
1613
|
+
ModelHandlerOutput wrapping created relationship details.
|
|
1614
|
+
|
|
1615
|
+
Raises:
|
|
1616
|
+
RuntimeHostError: If required fields missing or properties invalid.
|
|
1617
|
+
"""
|
|
1618
|
+
from_node_id = payload.get("from_node_id")
|
|
1619
|
+
to_node_id = payload.get("to_node_id")
|
|
1620
|
+
relationship_type = payload.get("relationship_type")
|
|
1621
|
+
|
|
1622
|
+
if from_node_id is None or to_node_id is None or relationship_type is None:
|
|
1623
|
+
raise RuntimeHostError(
|
|
1624
|
+
"Missing required fields: from_node_id, to_node_id, relationship_type",
|
|
1625
|
+
context=self._error_context(
|
|
1626
|
+
"graph.create_relationship", correlation_id
|
|
1627
|
+
),
|
|
1628
|
+
)
|
|
1629
|
+
|
|
1630
|
+
properties = payload.get("properties")
|
|
1631
|
+
props_dict: Mapping[str, JsonType] | None = None
|
|
1632
|
+
if properties is not None:
|
|
1633
|
+
if not isinstance(properties, dict):
|
|
1634
|
+
raise RuntimeHostError(
|
|
1635
|
+
"Invalid 'properties' in payload - must be a dict",
|
|
1636
|
+
context=self._error_context(
|
|
1637
|
+
"graph.create_relationship", correlation_id
|
|
1638
|
+
),
|
|
1639
|
+
)
|
|
1640
|
+
# Type ignore: dict variance - dict[str, object] to Mapping[str, JsonType]
|
|
1641
|
+
props_dict = properties # type: ignore[assignment]
|
|
1642
|
+
|
|
1643
|
+
try:
|
|
1644
|
+
result = await self.create_relationship(
|
|
1645
|
+
from_node_id=str(from_node_id),
|
|
1646
|
+
to_node_id=str(to_node_id),
|
|
1647
|
+
relationship_type=str(relationship_type),
|
|
1648
|
+
properties=props_dict,
|
|
1649
|
+
)
|
|
1650
|
+
except (InfraConnectionError, InfraAuthenticationError, RuntimeHostError):
|
|
1651
|
+
# Already has proper context, re-raise as-is
|
|
1652
|
+
raise
|
|
1653
|
+
except Exception as e:
|
|
1654
|
+
raise RuntimeHostError(
|
|
1655
|
+
f"Relationship creation failed: {e}",
|
|
1656
|
+
context=self._error_context(
|
|
1657
|
+
"graph.create_relationship", correlation_id
|
|
1658
|
+
),
|
|
1659
|
+
) from e
|
|
1660
|
+
|
|
1661
|
+
execute_payload = ModelGraphExecutePayload(
|
|
1662
|
+
cypher=f"CREATE ()-[:{relationship_type}]->()",
|
|
1663
|
+
counters={
|
|
1664
|
+
"relationships_created": 1,
|
|
1665
|
+
"relationship_id": result.id,
|
|
1666
|
+
"element_id": result.element_id,
|
|
1667
|
+
"type": result.type,
|
|
1668
|
+
"start_node_id": result.start_node_id,
|
|
1669
|
+
"end_node_id": result.end_node_id,
|
|
1670
|
+
},
|
|
1671
|
+
success=True,
|
|
1672
|
+
)
|
|
1673
|
+
|
|
1674
|
+
return self._build_graph_response(
|
|
1675
|
+
execute_payload, correlation_id, input_envelope_id
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
async def _delete_node_operation(
|
|
1679
|
+
self,
|
|
1680
|
+
payload: dict[str, object],
|
|
1681
|
+
correlation_id: UUID,
|
|
1682
|
+
input_envelope_id: UUID,
|
|
1683
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1684
|
+
"""Execute graph.delete_node operation.
|
|
1685
|
+
|
|
1686
|
+
Validates required 'node_id' and optional 'detach' boolean. The detach
|
|
1687
|
+
flag must be explicitly boolean to prevent accidental cascade deletes.
|
|
1688
|
+
|
|
1689
|
+
Args:
|
|
1690
|
+
payload: Request payload with 'node_id' and optional 'detach'.
|
|
1691
|
+
correlation_id: Correlation ID for tracing.
|
|
1692
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1693
|
+
|
|
1694
|
+
Returns:
|
|
1695
|
+
ModelHandlerOutput wrapping deletion result.
|
|
1696
|
+
|
|
1697
|
+
Raises:
|
|
1698
|
+
RuntimeHostError: If node_id missing or detach is non-boolean.
|
|
1699
|
+
"""
|
|
1700
|
+
node_id = payload.get("node_id")
|
|
1701
|
+
if node_id is None:
|
|
1702
|
+
raise RuntimeHostError(
|
|
1703
|
+
"Missing required field: node_id",
|
|
1704
|
+
context=self._error_context("graph.delete_node", correlation_id),
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
# Validate detach is boolean - don't silently coerce to prevent accidental deletes
|
|
1708
|
+
detach_raw = payload.get("detach", False)
|
|
1709
|
+
if not isinstance(detach_raw, bool):
|
|
1710
|
+
raise RuntimeHostError(
|
|
1711
|
+
f"Invalid 'detach' in payload - must be boolean, "
|
|
1712
|
+
f"got {type(detach_raw).__name__}",
|
|
1713
|
+
context=self._error_context("graph.delete_node", correlation_id),
|
|
1714
|
+
)
|
|
1715
|
+
detach = detach_raw
|
|
1716
|
+
|
|
1717
|
+
try:
|
|
1718
|
+
result = await self.delete_node(str(node_id), detach=detach)
|
|
1719
|
+
except (InfraConnectionError, InfraAuthenticationError, RuntimeHostError):
|
|
1720
|
+
# Already has proper context, re-raise as-is
|
|
1721
|
+
raise
|
|
1722
|
+
except Exception as e:
|
|
1723
|
+
raise RuntimeHostError(
|
|
1724
|
+
f"Node deletion failed: {e}",
|
|
1725
|
+
context=self._error_context("graph.delete_node", correlation_id),
|
|
1726
|
+
) from e
|
|
1727
|
+
|
|
1728
|
+
execute_payload = ModelGraphExecutePayload(
|
|
1729
|
+
cypher=f"{'DETACH ' if detach else ''}DELETE (n)",
|
|
1730
|
+
counters={
|
|
1731
|
+
"nodes_deleted": 1 if result.success else 0,
|
|
1732
|
+
"relationships_deleted": result.relationships_deleted,
|
|
1733
|
+
"execution_time_ms": result.execution_time_ms,
|
|
1734
|
+
},
|
|
1735
|
+
success=result.success,
|
|
1736
|
+
)
|
|
1737
|
+
|
|
1738
|
+
return self._build_graph_response(
|
|
1739
|
+
execute_payload, correlation_id, input_envelope_id
|
|
1740
|
+
)
|
|
1741
|
+
|
|
1742
|
+
async def _delete_relationship_operation(
|
|
1743
|
+
self,
|
|
1744
|
+
payload: dict[str, object],
|
|
1745
|
+
correlation_id: UUID,
|
|
1746
|
+
input_envelope_id: UUID,
|
|
1747
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1748
|
+
"""Execute graph.delete_relationship operation.
|
|
1749
|
+
|
|
1750
|
+
Validates required 'relationship_id' field, then delegates to
|
|
1751
|
+
delete_relationship() method.
|
|
1752
|
+
|
|
1753
|
+
Args:
|
|
1754
|
+
payload: Request payload with 'relationship_id'.
|
|
1755
|
+
correlation_id: Correlation ID for tracing.
|
|
1756
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1757
|
+
|
|
1758
|
+
Returns:
|
|
1759
|
+
ModelHandlerOutput wrapping deletion result.
|
|
1760
|
+
|
|
1761
|
+
Raises:
|
|
1762
|
+
RuntimeHostError: If relationship_id field is missing.
|
|
1763
|
+
"""
|
|
1764
|
+
relationship_id = payload.get("relationship_id")
|
|
1765
|
+
if relationship_id is None:
|
|
1766
|
+
raise RuntimeHostError(
|
|
1767
|
+
"Missing required field: relationship_id",
|
|
1768
|
+
context=self._error_context(
|
|
1769
|
+
"graph.delete_relationship", correlation_id
|
|
1770
|
+
),
|
|
1771
|
+
)
|
|
1772
|
+
|
|
1773
|
+
try:
|
|
1774
|
+
result = await self.delete_relationship(str(relationship_id))
|
|
1775
|
+
except (InfraConnectionError, InfraAuthenticationError, RuntimeHostError):
|
|
1776
|
+
# Already has proper context, re-raise as-is
|
|
1777
|
+
raise
|
|
1778
|
+
except Exception as e:
|
|
1779
|
+
raise RuntimeHostError(
|
|
1780
|
+
f"Relationship deletion failed: {e}",
|
|
1781
|
+
context=self._error_context(
|
|
1782
|
+
"graph.delete_relationship", correlation_id
|
|
1783
|
+
),
|
|
1784
|
+
) from e
|
|
1785
|
+
|
|
1786
|
+
execute_payload = ModelGraphExecutePayload(
|
|
1787
|
+
cypher="DELETE [r]",
|
|
1788
|
+
counters={
|
|
1789
|
+
"relationships_deleted": result.relationships_deleted,
|
|
1790
|
+
"execution_time_ms": result.execution_time_ms,
|
|
1791
|
+
},
|
|
1792
|
+
success=result.success,
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1795
|
+
return self._build_graph_response(
|
|
1796
|
+
execute_payload, correlation_id, input_envelope_id
|
|
1797
|
+
)
|
|
1798
|
+
|
|
1799
|
+
async def _traverse_operation(
|
|
1800
|
+
self,
|
|
1801
|
+
payload: dict[str, object],
|
|
1802
|
+
correlation_id: UUID,
|
|
1803
|
+
input_envelope_id: UUID,
|
|
1804
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1805
|
+
"""Execute graph.traverse operation.
|
|
1806
|
+
|
|
1807
|
+
Validates traversal parameters with strict type checking:
|
|
1808
|
+
- 'start_node_id': required
|
|
1809
|
+
- 'relationship_types': optional list of strings
|
|
1810
|
+
- 'direction': optional, must be 'outgoing', 'incoming', or 'both'
|
|
1811
|
+
- 'max_depth': optional, must be positive integer
|
|
1812
|
+
- 'filters': optional dict with 'node_labels' (list) and 'node_properties' (dict)
|
|
1813
|
+
|
|
1814
|
+
Args:
|
|
1815
|
+
payload: Request payload with traversal configuration.
|
|
1816
|
+
correlation_id: Correlation ID for tracing.
|
|
1817
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1818
|
+
|
|
1819
|
+
Returns:
|
|
1820
|
+
ModelHandlerOutput wrapping traversal results (nodes, relationships, paths).
|
|
1821
|
+
|
|
1822
|
+
Raises:
|
|
1823
|
+
RuntimeHostError: If start_node_id missing or parameters have invalid types.
|
|
1824
|
+
"""
|
|
1825
|
+
start_node_id = payload.get("start_node_id")
|
|
1826
|
+
if start_node_id is None:
|
|
1827
|
+
raise RuntimeHostError(
|
|
1828
|
+
"Missing required field: start_node_id",
|
|
1829
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1830
|
+
)
|
|
1831
|
+
|
|
1832
|
+
# relationship_types - optional, but if provided must be list
|
|
1833
|
+
relationship_types = payload.get("relationship_types")
|
|
1834
|
+
rel_types: list[str] | None = None
|
|
1835
|
+
if relationship_types is not None:
|
|
1836
|
+
if not isinstance(relationship_types, list):
|
|
1837
|
+
raise RuntimeHostError(
|
|
1838
|
+
f"Invalid 'relationship_types' - must be list, "
|
|
1839
|
+
f"got {type(relationship_types).__name__}",
|
|
1840
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1841
|
+
)
|
|
1842
|
+
rel_types = [str(rt) for rt in relationship_types]
|
|
1843
|
+
|
|
1844
|
+
# direction - optional with default, but if provided must be valid string
|
|
1845
|
+
direction_raw = payload.get("direction")
|
|
1846
|
+
if direction_raw is None:
|
|
1847
|
+
direction = "outgoing"
|
|
1848
|
+
elif not isinstance(direction_raw, str):
|
|
1849
|
+
raise RuntimeHostError(
|
|
1850
|
+
f"Invalid 'direction' - must be string, "
|
|
1851
|
+
f"got {type(direction_raw).__name__}",
|
|
1852
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1853
|
+
)
|
|
1854
|
+
elif direction_raw not in ("outgoing", "incoming", "both"):
|
|
1855
|
+
raise RuntimeHostError(
|
|
1856
|
+
f"Invalid 'direction' value '{direction_raw}' - "
|
|
1857
|
+
f"must be 'outgoing', 'incoming', or 'both'",
|
|
1858
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1859
|
+
)
|
|
1860
|
+
else:
|
|
1861
|
+
direction = direction_raw
|
|
1862
|
+
|
|
1863
|
+
# max_depth - optional with default, but if provided must be positive integer
|
|
1864
|
+
max_depth_raw = payload.get("max_depth")
|
|
1865
|
+
if max_depth_raw is None:
|
|
1866
|
+
max_depth = 1
|
|
1867
|
+
elif not isinstance(max_depth_raw, int | float):
|
|
1868
|
+
raise RuntimeHostError(
|
|
1869
|
+
f"Invalid 'max_depth' - must be int or float, "
|
|
1870
|
+
f"got {type(max_depth_raw).__name__}",
|
|
1871
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1872
|
+
)
|
|
1873
|
+
else:
|
|
1874
|
+
max_depth = int(max_depth_raw)
|
|
1875
|
+
if max_depth <= 0:
|
|
1876
|
+
raise RuntimeHostError(
|
|
1877
|
+
f"Invalid 'max_depth' value {max_depth} - must be a positive integer",
|
|
1878
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
# filters - optional, but if provided must be dict with validated fields
|
|
1882
|
+
filters = None
|
|
1883
|
+
filters_raw = payload.get("filters")
|
|
1884
|
+
if filters_raw is not None:
|
|
1885
|
+
if not isinstance(filters_raw, dict):
|
|
1886
|
+
raise RuntimeHostError(
|
|
1887
|
+
f"Invalid 'filters' - must be dict, "
|
|
1888
|
+
f"got {type(filters_raw).__name__}",
|
|
1889
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1890
|
+
)
|
|
1891
|
+
|
|
1892
|
+
# Validate node_labels - must be list or None
|
|
1893
|
+
node_labels = filters_raw.get("node_labels")
|
|
1894
|
+
if node_labels is not None and not isinstance(node_labels, list):
|
|
1895
|
+
raise RuntimeHostError(
|
|
1896
|
+
f"Invalid 'filters.node_labels' - must be list, "
|
|
1897
|
+
f"got {type(node_labels).__name__}",
|
|
1898
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
# Validate node_properties - must be dict or None
|
|
1902
|
+
node_properties = filters_raw.get("node_properties")
|
|
1903
|
+
if node_properties is not None and not isinstance(node_properties, dict):
|
|
1904
|
+
raise RuntimeHostError(
|
|
1905
|
+
f"Invalid 'filters.node_properties' - must be dict, "
|
|
1906
|
+
f"got {type(node_properties).__name__}",
|
|
1907
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1908
|
+
)
|
|
1909
|
+
|
|
1910
|
+
# Type ignore: list[object] to list[str] - validated above as list
|
|
1911
|
+
# Type ignore: dict[str, object] to dict[str, JsonType] - validated above as dict
|
|
1912
|
+
filters = ModelGraphTraversalFilters(
|
|
1913
|
+
node_labels=node_labels, # type: ignore[arg-type]
|
|
1914
|
+
node_properties=node_properties, # type: ignore[arg-type]
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
try:
|
|
1918
|
+
result = await self.traverse(
|
|
1919
|
+
start_node_id=str(start_node_id),
|
|
1920
|
+
relationship_types=rel_types,
|
|
1921
|
+
direction=direction,
|
|
1922
|
+
max_depth=max_depth,
|
|
1923
|
+
filters=filters,
|
|
1924
|
+
)
|
|
1925
|
+
except (InfraConnectionError, InfraAuthenticationError, RuntimeHostError):
|
|
1926
|
+
# Already has proper context, re-raise as-is
|
|
1927
|
+
raise
|
|
1928
|
+
except Exception as e:
|
|
1929
|
+
raise RuntimeHostError(
|
|
1930
|
+
f"Traversal failed: {e}",
|
|
1931
|
+
context=self._error_context("graph.traverse", correlation_id),
|
|
1932
|
+
) from e
|
|
1933
|
+
|
|
1934
|
+
# Convert nodes to records
|
|
1935
|
+
records = []
|
|
1936
|
+
for node in result.nodes:
|
|
1937
|
+
records.append(
|
|
1938
|
+
ModelGraphRecord(
|
|
1939
|
+
data={
|
|
1940
|
+
"id": node.id,
|
|
1941
|
+
"element_id": node.element_id,
|
|
1942
|
+
"labels": node.labels,
|
|
1943
|
+
"properties": node.properties,
|
|
1944
|
+
}
|
|
1945
|
+
)
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1948
|
+
query_payload = ModelGraphQueryPayload(
|
|
1949
|
+
cypher=f"TRAVERSE from {start_node_id}",
|
|
1950
|
+
records=records,
|
|
1951
|
+
summary={
|
|
1952
|
+
"depth_reached": result.depth_reached,
|
|
1953
|
+
"nodes_found": len(result.nodes),
|
|
1954
|
+
"relationships_found": len(result.relationships),
|
|
1955
|
+
"paths_found": len(result.paths),
|
|
1956
|
+
"execution_time_ms": result.execution_time_ms,
|
|
1957
|
+
},
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
return self._build_graph_response(
|
|
1961
|
+
query_payload, correlation_id, input_envelope_id
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
def _error_context(
|
|
1965
|
+
self, operation: str, correlation_id: UUID
|
|
1966
|
+
) -> ModelInfraErrorContext:
|
|
1967
|
+
"""Create standardized error context for graph operations.
|
|
1968
|
+
|
|
1969
|
+
Args:
|
|
1970
|
+
operation: The operation name (e.g., "graph.execute_query").
|
|
1971
|
+
correlation_id: Correlation ID for tracing.
|
|
1972
|
+
|
|
1973
|
+
Returns:
|
|
1974
|
+
ModelInfraErrorContext configured for graph handler.
|
|
1975
|
+
"""
|
|
1976
|
+
return ModelInfraErrorContext.with_correlation(
|
|
1977
|
+
transport_type=EnumInfraTransportType.GRAPH,
|
|
1978
|
+
operation=operation,
|
|
1979
|
+
target_name="graph_handler",
|
|
1980
|
+
correlation_id=correlation_id,
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
def _build_graph_response(
|
|
1984
|
+
self,
|
|
1985
|
+
typed_payload: ModelGraphQueryPayload | ModelGraphExecutePayload,
|
|
1986
|
+
correlation_id: UUID,
|
|
1987
|
+
input_envelope_id: UUID,
|
|
1988
|
+
) -> ModelHandlerOutput[ModelGraphHandlerResponse]:
|
|
1989
|
+
"""Build standardized ModelGraphHandlerResponse wrapped in ModelHandlerOutput.
|
|
1990
|
+
|
|
1991
|
+
This helper method ensures consistent response formatting across all
|
|
1992
|
+
graph operations, matching the pattern used by HandlerDb and HandlerConsul.
|
|
1993
|
+
|
|
1994
|
+
Args:
|
|
1995
|
+
typed_payload: Strongly-typed payload (query or execute).
|
|
1996
|
+
correlation_id: Correlation ID for tracing.
|
|
1997
|
+
input_envelope_id: Input envelope ID for causality tracking.
|
|
1998
|
+
|
|
1999
|
+
Returns:
|
|
2000
|
+
ModelHandlerOutput wrapping ModelGraphHandlerResponse.
|
|
2001
|
+
"""
|
|
2002
|
+
response = ModelGraphHandlerResponse(
|
|
2003
|
+
status=EnumResponseStatus.SUCCESS,
|
|
2004
|
+
payload=ModelGraphHandlerPayload(data=typed_payload),
|
|
2005
|
+
correlation_id=correlation_id,
|
|
2006
|
+
)
|
|
2007
|
+
return ModelHandlerOutput.for_compute(
|
|
2008
|
+
input_envelope_id=input_envelope_id,
|
|
2009
|
+
correlation_id=correlation_id,
|
|
2010
|
+
handler_id=HANDLER_ID_GRAPH,
|
|
2011
|
+
result=response,
|
|
2012
|
+
)
|
|
2013
|
+
|
|
1158
2014
|
|
|
1159
|
-
__all__: list[str] = ["HandlerGraph", "HANDLER_ID_GRAPH"]
|
|
2015
|
+
__all__: list[str] = ["HandlerGraph", "HANDLER_ID_GRAPH", "SUPPORTED_OPERATIONS"]
|