osscodeiq 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- osscodeiq/__init__.py +0 -0
- osscodeiq/analyzer.py +467 -0
- osscodeiq/cache/__init__.py +0 -0
- osscodeiq/cache/hasher.py +23 -0
- osscodeiq/cache/store.py +300 -0
- osscodeiq/classifiers/__init__.py +0 -0
- osscodeiq/classifiers/layer_classifier.py +69 -0
- osscodeiq/cli.py +721 -0
- osscodeiq/config.py +113 -0
- osscodeiq/detectors/__init__.py +0 -0
- osscodeiq/detectors/auth/__init__.py +0 -0
- osscodeiq/detectors/auth/certificate_auth.py +139 -0
- osscodeiq/detectors/auth/ldap_auth.py +89 -0
- osscodeiq/detectors/auth/session_header_auth.py +120 -0
- osscodeiq/detectors/base.py +41 -0
- osscodeiq/detectors/config/__init__.py +0 -0
- osscodeiq/detectors/config/batch_structure.py +128 -0
- osscodeiq/detectors/config/cloudformation.py +183 -0
- osscodeiq/detectors/config/docker_compose.py +179 -0
- osscodeiq/detectors/config/github_actions.py +150 -0
- osscodeiq/detectors/config/gitlab_ci.py +216 -0
- osscodeiq/detectors/config/helm_chart.py +187 -0
- osscodeiq/detectors/config/ini_structure.py +101 -0
- osscodeiq/detectors/config/json_structure.py +72 -0
- osscodeiq/detectors/config/kubernetes.py +305 -0
- osscodeiq/detectors/config/kubernetes_rbac.py +212 -0
- osscodeiq/detectors/config/openapi.py +194 -0
- osscodeiq/detectors/config/package_json.py +99 -0
- osscodeiq/detectors/config/properties_detector.py +108 -0
- osscodeiq/detectors/config/pyproject_toml.py +169 -0
- osscodeiq/detectors/config/sql_structure.py +155 -0
- osscodeiq/detectors/config/toml_structure.py +93 -0
- osscodeiq/detectors/config/tsconfig_json.py +105 -0
- osscodeiq/detectors/config/yaml_structure.py +82 -0
- osscodeiq/detectors/cpp/__init__.py +0 -0
- osscodeiq/detectors/cpp/cpp_structures.py +192 -0
- osscodeiq/detectors/csharp/__init__.py +0 -0
- osscodeiq/detectors/csharp/csharp_efcore.py +184 -0
- osscodeiq/detectors/csharp/csharp_minimal_apis.py +156 -0
- osscodeiq/detectors/csharp/csharp_structures.py +317 -0
- osscodeiq/detectors/docs/__init__.py +0 -0
- osscodeiq/detectors/docs/markdown_structure.py +117 -0
- osscodeiq/detectors/frontend/__init__.py +0 -0
- osscodeiq/detectors/frontend/angular_components.py +177 -0
- osscodeiq/detectors/frontend/frontend_routes.py +259 -0
- osscodeiq/detectors/frontend/react_components.py +148 -0
- osscodeiq/detectors/frontend/svelte_components.py +84 -0
- osscodeiq/detectors/frontend/vue_components.py +150 -0
- osscodeiq/detectors/generic/__init__.py +1 -0
- osscodeiq/detectors/generic/imports_detector.py +413 -0
- osscodeiq/detectors/go/__init__.py +0 -0
- osscodeiq/detectors/go/go_orm.py +202 -0
- osscodeiq/detectors/go/go_structures.py +162 -0
- osscodeiq/detectors/go/go_web.py +157 -0
- osscodeiq/detectors/iac/__init__.py +0 -0
- osscodeiq/detectors/iac/bicep.py +135 -0
- osscodeiq/detectors/iac/dockerfile.py +182 -0
- osscodeiq/detectors/iac/terraform.py +188 -0
- osscodeiq/detectors/java/__init__.py +0 -0
- osscodeiq/detectors/java/azure_functions.py +424 -0
- osscodeiq/detectors/java/azure_messaging.py +350 -0
- osscodeiq/detectors/java/class_hierarchy.py +349 -0
- osscodeiq/detectors/java/config_def.py +82 -0
- osscodeiq/detectors/java/cosmos_db.py +105 -0
- osscodeiq/detectors/java/graphql_resolver.py +188 -0
- osscodeiq/detectors/java/grpc_service.py +142 -0
- osscodeiq/detectors/java/ibm_mq.py +178 -0
- osscodeiq/detectors/java/jaxrs.py +160 -0
- osscodeiq/detectors/java/jdbc.py +196 -0
- osscodeiq/detectors/java/jms.py +116 -0
- osscodeiq/detectors/java/jpa_entity.py +143 -0
- osscodeiq/detectors/java/kafka.py +113 -0
- osscodeiq/detectors/java/kafka_protocol.py +70 -0
- osscodeiq/detectors/java/micronaut.py +248 -0
- osscodeiq/detectors/java/module_deps.py +191 -0
- osscodeiq/detectors/java/public_api.py +206 -0
- osscodeiq/detectors/java/quarkus.py +176 -0
- osscodeiq/detectors/java/rabbitmq.py +150 -0
- osscodeiq/detectors/java/raw_sql.py +136 -0
- osscodeiq/detectors/java/repository.py +131 -0
- osscodeiq/detectors/java/rmi.py +129 -0
- osscodeiq/detectors/java/spring_events.py +117 -0
- osscodeiq/detectors/java/spring_rest.py +168 -0
- osscodeiq/detectors/java/spring_security.py +212 -0
- osscodeiq/detectors/java/tibco_ems.py +193 -0
- osscodeiq/detectors/java/websocket.py +188 -0
- osscodeiq/detectors/kotlin/__init__.py +0 -0
- osscodeiq/detectors/kotlin/kotlin_structures.py +124 -0
- osscodeiq/detectors/kotlin/ktor_routes.py +163 -0
- osscodeiq/detectors/proto/__init__.py +0 -0
- osscodeiq/detectors/proto/proto_structure.py +153 -0
- osscodeiq/detectors/python/__init__.py +0 -0
- osscodeiq/detectors/python/celery_tasks.py +88 -0
- osscodeiq/detectors/python/django_auth.py +132 -0
- osscodeiq/detectors/python/django_models.py +157 -0
- osscodeiq/detectors/python/django_views.py +74 -0
- osscodeiq/detectors/python/fastapi_auth.py +143 -0
- osscodeiq/detectors/python/fastapi_routes.py +68 -0
- osscodeiq/detectors/python/flask_routes.py +67 -0
- osscodeiq/detectors/python/kafka_python.py +175 -0
- osscodeiq/detectors/python/pydantic_models.py +115 -0
- osscodeiq/detectors/python/python_structures.py +234 -0
- osscodeiq/detectors/python/sqlalchemy_models.py +82 -0
- osscodeiq/detectors/registry.py +100 -0
- osscodeiq/detectors/rust/__init__.py +0 -0
- osscodeiq/detectors/rust/actix_web.py +234 -0
- osscodeiq/detectors/rust/rust_structures.py +174 -0
- osscodeiq/detectors/scala/__init__.py +0 -0
- osscodeiq/detectors/scala/scala_structures.py +128 -0
- osscodeiq/detectors/shell/__init__.py +0 -0
- osscodeiq/detectors/shell/bash_detector.py +127 -0
- osscodeiq/detectors/shell/powershell_detector.py +118 -0
- osscodeiq/detectors/typescript/__init__.py +0 -0
- osscodeiq/detectors/typescript/express_routes.py +55 -0
- osscodeiq/detectors/typescript/fastify_routes.py +156 -0
- osscodeiq/detectors/typescript/graphql_resolvers.py +100 -0
- osscodeiq/detectors/typescript/kafka_js.py +164 -0
- osscodeiq/detectors/typescript/mongoose_orm.py +151 -0
- osscodeiq/detectors/typescript/nestjs_controllers.py +99 -0
- osscodeiq/detectors/typescript/nestjs_guards.py +138 -0
- osscodeiq/detectors/typescript/passport_jwt.py +133 -0
- osscodeiq/detectors/typescript/prisma_orm.py +96 -0
- osscodeiq/detectors/typescript/remix_routes.py +160 -0
- osscodeiq/detectors/typescript/sequelize_orm.py +136 -0
- osscodeiq/detectors/typescript/typeorm_entities.py +86 -0
- osscodeiq/detectors/typescript/typescript_structures.py +185 -0
- osscodeiq/detectors/utils.py +49 -0
- osscodeiq/discovery/__init__.py +11 -0
- osscodeiq/discovery/change_detector.py +97 -0
- osscodeiq/discovery/file_discovery.py +342 -0
- osscodeiq/flow/__init__.py +0 -0
- osscodeiq/flow/engine.py +78 -0
- osscodeiq/flow/models.py +72 -0
- osscodeiq/flow/renderer.py +127 -0
- osscodeiq/flow/templates/interactive.html +252 -0
- osscodeiq/flow/vendor/cytoscape-dagre.min.js +8 -0
- osscodeiq/flow/vendor/cytoscape.min.js +32 -0
- osscodeiq/flow/vendor/dagre.min.js +3809 -0
- osscodeiq/flow/views.py +357 -0
- osscodeiq/graph/__init__.py +0 -0
- osscodeiq/graph/backend.py +52 -0
- osscodeiq/graph/backends/__init__.py +23 -0
- osscodeiq/graph/backends/kuzu.py +576 -0
- osscodeiq/graph/backends/networkx.py +135 -0
- osscodeiq/graph/backends/sqlite_backend.py +406 -0
- osscodeiq/graph/builder.py +297 -0
- osscodeiq/graph/query.py +228 -0
- osscodeiq/graph/store.py +183 -0
- osscodeiq/graph/views.py +231 -0
- osscodeiq/models/__init__.py +17 -0
- osscodeiq/models/graph.py +116 -0
- osscodeiq/output/__init__.py +0 -0
- osscodeiq/output/dot.py +171 -0
- osscodeiq/output/mermaid.py +160 -0
- osscodeiq/output/safety.py +58 -0
- osscodeiq/output/serializers.py +42 -0
- osscodeiq/parsing/__init__.py +5 -0
- osscodeiq/parsing/languages/__init__.py +0 -0
- osscodeiq/parsing/languages/base.py +23 -0
- osscodeiq/parsing/languages/java.py +68 -0
- osscodeiq/parsing/languages/python.py +57 -0
- osscodeiq/parsing/languages/typescript.py +95 -0
- osscodeiq/parsing/parser_manager.py +125 -0
- osscodeiq/parsing/structured/__init__.py +0 -0
- osscodeiq/parsing/structured/gradle_parser.py +78 -0
- osscodeiq/parsing/structured/json_parser.py +24 -0
- osscodeiq/parsing/structured/properties_parser.py +56 -0
- osscodeiq/parsing/structured/sql_parser.py +54 -0
- osscodeiq/parsing/structured/xml_parser.py +148 -0
- osscodeiq/parsing/structured/yaml_parser.py +38 -0
- osscodeiq/server/__init__.py +7 -0
- osscodeiq/server/app.py +53 -0
- osscodeiq/server/mcp_server.py +174 -0
- osscodeiq/server/middleware.py +16 -0
- osscodeiq/server/routes.py +184 -0
- osscodeiq/server/service.py +445 -0
- osscodeiq/server/templates/welcome.html +56 -0
- osscodeiq-0.0.0.dist-info/METADATA +30 -0
- osscodeiq-0.0.0.dist-info/RECORD +183 -0
- osscodeiq-0.0.0.dist-info/WHEEL +5 -0
- osscodeiq-0.0.0.dist-info/entry_points.txt +2 -0
- osscodeiq-0.0.0.dist-info/licenses/LICENSE +21 -0
- osscodeiq-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""NetworkX-backed graph backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
from osscodeiq.models.graph import (
|
|
11
|
+
EdgeKind,
|
|
12
|
+
GraphEdge,
|
|
13
|
+
GraphNode,
|
|
14
|
+
NodeKind,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NetworkXBackend:
|
|
21
|
+
"""In-memory graph backend using NetworkX MultiDiGraph."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._g: nx.MultiDiGraph = nx.MultiDiGraph()
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def node_count(self) -> int:
|
|
28
|
+
return self._g.number_of_nodes()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def edge_count(self) -> int:
|
|
32
|
+
return self._g.number_of_edges()
|
|
33
|
+
|
|
34
|
+
def add_node(self, node: GraphNode) -> None:
|
|
35
|
+
if node.id in self._g:
|
|
36
|
+
logger.debug("Duplicate node ID %s, keeping first", node.id)
|
|
37
|
+
return
|
|
38
|
+
self._g.add_node(node.id, **node.model_dump())
|
|
39
|
+
|
|
40
|
+
def add_edge(self, edge: GraphEdge) -> None:
|
|
41
|
+
# Only add edge if both nodes exist — prevents NetworkX from
|
|
42
|
+
# auto-creating phantom nodes for dangling references.
|
|
43
|
+
if edge.source not in self._g or edge.target not in self._g:
|
|
44
|
+
logger.debug(
|
|
45
|
+
"Skipping edge %s -> %s: missing node(s)",
|
|
46
|
+
edge.source, edge.target,
|
|
47
|
+
)
|
|
48
|
+
return
|
|
49
|
+
self._g.add_edge(edge.source, edge.target, **edge.model_dump())
|
|
50
|
+
|
|
51
|
+
def clear(self) -> None:
|
|
52
|
+
self._g.clear()
|
|
53
|
+
|
|
54
|
+
def get_node(self, node_id: str) -> GraphNode | None:
|
|
55
|
+
if node_id not in self._g:
|
|
56
|
+
return None
|
|
57
|
+
return GraphNode(**self._g.nodes[node_id])
|
|
58
|
+
|
|
59
|
+
def has_node(self, node_id: str) -> bool:
|
|
60
|
+
return node_id in self._g
|
|
61
|
+
|
|
62
|
+
def get_edges_between(self, source: str, target: str) -> list[GraphEdge]:
|
|
63
|
+
if not self._g.has_edge(source, target):
|
|
64
|
+
return []
|
|
65
|
+
return [GraphEdge(**data) for _key, data in self._g[source][target].items()]
|
|
66
|
+
|
|
67
|
+
def all_nodes(self) -> list[GraphNode]:
|
|
68
|
+
return [
|
|
69
|
+
GraphNode(**data)
|
|
70
|
+
for _, data in self._g.nodes(data=True)
|
|
71
|
+
if "id" in data and "kind" in data
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def all_edges(self) -> list[GraphEdge]:
|
|
75
|
+
return [
|
|
76
|
+
GraphEdge(**data)
|
|
77
|
+
for _, _, data in self._g.edges(data=True)
|
|
78
|
+
if "source" in data and "target" in data
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
def nodes_by_kind(self, kind: NodeKind) -> list[GraphNode]:
|
|
82
|
+
return [
|
|
83
|
+
GraphNode(**data)
|
|
84
|
+
for _, data in self._g.nodes(data=True)
|
|
85
|
+
if data.get("kind") == kind.value and "id" in data
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
def edges_by_kind(self, kind: EdgeKind) -> list[GraphEdge]:
|
|
89
|
+
return [
|
|
90
|
+
GraphEdge(**data)
|
|
91
|
+
for _, _, data in self._g.edges(data=True)
|
|
92
|
+
if data.get("kind") == kind.value and "source" in data
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def neighbors(self, node_id: str, edge_kinds: set[EdgeKind] | None = None, direction: str = "both") -> list[str]:
|
|
96
|
+
result: set[str] = set()
|
|
97
|
+
if direction in ("out", "both"):
|
|
98
|
+
for _, target, data in self._g.out_edges(node_id, data=True):
|
|
99
|
+
if edge_kinds is None or EdgeKind(data.get("kind", "")) in edge_kinds:
|
|
100
|
+
result.add(target)
|
|
101
|
+
if direction in ("in", "both"):
|
|
102
|
+
for source, _, data in self._g.in_edges(node_id, data=True):
|
|
103
|
+
if edge_kinds is None or EdgeKind(data.get("kind", "")) in edge_kinds:
|
|
104
|
+
result.add(source)
|
|
105
|
+
return sorted(result)
|
|
106
|
+
|
|
107
|
+
def find_cycles(self, limit: int = 100) -> list[list[str]]:
|
|
108
|
+
cycles: list[list[str]] = []
|
|
109
|
+
for cycle in nx.simple_cycles(self._g):
|
|
110
|
+
cycles.append(cycle)
|
|
111
|
+
if len(cycles) >= limit:
|
|
112
|
+
break
|
|
113
|
+
return cycles
|
|
114
|
+
|
|
115
|
+
def shortest_path(self, source: str, target: str) -> list[str] | None:
|
|
116
|
+
try:
|
|
117
|
+
return nx.shortest_path(self._g, source, target)
|
|
118
|
+
except nx.NetworkXNoPath:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def subgraph(self, node_ids: set[str]) -> NetworkXBackend:
|
|
122
|
+
new_backend = NetworkXBackend()
|
|
123
|
+
sub = self._g.subgraph(node_ids)
|
|
124
|
+
new_backend._g = nx.MultiDiGraph(sub)
|
|
125
|
+
return new_backend
|
|
126
|
+
|
|
127
|
+
def update_node_properties(self, node_id: str, properties: dict[str, Any]) -> None:
|
|
128
|
+
if node_id in self._g:
|
|
129
|
+
data = self._g.nodes[node_id]
|
|
130
|
+
props = data.get("properties", {})
|
|
131
|
+
props.update(properties)
|
|
132
|
+
data["properties"] = props
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
pass # In-memory, nothing to close
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""SQLite-backed graph backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sqlite3
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import networkx as nx
|
|
11
|
+
|
|
12
|
+
from osscodeiq.models.graph import (
|
|
13
|
+
EdgeKind,
|
|
14
|
+
GraphEdge,
|
|
15
|
+
GraphNode,
|
|
16
|
+
NodeKind,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_SCHEMA_SQL = """
|
|
22
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
kind TEXT NOT NULL,
|
|
25
|
+
label TEXT NOT NULL,
|
|
26
|
+
data JSON NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
29
|
+
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
source TEXT NOT NULL,
|
|
31
|
+
target TEXT NOT NULL,
|
|
32
|
+
kind TEXT NOT NULL,
|
|
33
|
+
data JSON NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _serialize_node(node: GraphNode) -> tuple[str, str, str, str]:
|
|
43
|
+
"""Serialize a GraphNode to a tuple suitable for INSERT."""
|
|
44
|
+
return (
|
|
45
|
+
node.id,
|
|
46
|
+
node.kind.value,
|
|
47
|
+
node.label,
|
|
48
|
+
json.dumps(node.model_dump(mode="json")),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _deserialize_node(data_json: str) -> GraphNode:
|
|
53
|
+
"""Reconstruct a GraphNode from its JSON representation."""
|
|
54
|
+
return GraphNode(**json.loads(data_json))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _serialize_edge(edge: GraphEdge) -> tuple[str, str, str, str]:
|
|
58
|
+
"""Serialize a GraphEdge to a tuple suitable for INSERT."""
|
|
59
|
+
return (
|
|
60
|
+
edge.source,
|
|
61
|
+
edge.target,
|
|
62
|
+
edge.kind.value,
|
|
63
|
+
json.dumps(edge.model_dump(mode="json")),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _deserialize_edge(data_json: str) -> GraphEdge:
|
|
68
|
+
"""Reconstruct a GraphEdge from its JSON representation."""
|
|
69
|
+
return GraphEdge(**json.loads(data_json))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SqliteGraphBackend:
|
|
73
|
+
"""Persistent graph backend using SQLite.
|
|
74
|
+
|
|
75
|
+
Stores nodes and edges in a SQLite database with JSON-serialized
|
|
76
|
+
Pydantic model data. Uses WAL journal mode for good write concurrency
|
|
77
|
+
and indexes on ``kind``, ``source``, and ``target`` columns.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, db_path: str) -> None:
|
|
81
|
+
self._db_path = db_path
|
|
82
|
+
try:
|
|
83
|
+
self._conn = sqlite3.connect(db_path)
|
|
84
|
+
self._conn.row_factory = sqlite3.Row
|
|
85
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
86
|
+
self._conn.execute("PRAGMA synchronous=NORMAL")
|
|
87
|
+
self._conn.executescript(_SCHEMA_SQL)
|
|
88
|
+
self._conn.commit()
|
|
89
|
+
except sqlite3.Error:
|
|
90
|
+
logger.exception("Failed to initialize SQLite backend at %s", db_path)
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Properties
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def node_count(self) -> int:
|
|
99
|
+
try:
|
|
100
|
+
row = self._conn.execute("SELECT COUNT(*) FROM nodes").fetchone()
|
|
101
|
+
return row[0]
|
|
102
|
+
except sqlite3.Error:
|
|
103
|
+
logger.exception("Failed to count nodes")
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def edge_count(self) -> int:
|
|
108
|
+
try:
|
|
109
|
+
row = self._conn.execute("SELECT COUNT(*) FROM edges").fetchone()
|
|
110
|
+
return row[0]
|
|
111
|
+
except sqlite3.Error:
|
|
112
|
+
logger.exception("Failed to count edges")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
# Mutations
|
|
117
|
+
# ------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def add_node(self, node: GraphNode) -> None:
|
|
120
|
+
try:
|
|
121
|
+
self._conn.execute(
|
|
122
|
+
"INSERT OR IGNORE INTO nodes VALUES (?, ?, ?, ?)",
|
|
123
|
+
_serialize_node(node),
|
|
124
|
+
)
|
|
125
|
+
self._conn.commit()
|
|
126
|
+
except sqlite3.Error:
|
|
127
|
+
logger.exception("Failed to add node %s", node.id)
|
|
128
|
+
|
|
129
|
+
def add_edge(self, edge: GraphEdge) -> None:
|
|
130
|
+
# Only add edge if both nodes exist — consistent with KuzuDB/Neo4j behavior
|
|
131
|
+
if not self.has_node(edge.source) or not self.has_node(edge.target):
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
self._conn.execute(
|
|
135
|
+
"INSERT INTO edges (source, target, kind, data) VALUES (?, ?, ?, ?)",
|
|
136
|
+
_serialize_edge(edge),
|
|
137
|
+
)
|
|
138
|
+
self._conn.commit()
|
|
139
|
+
except sqlite3.Error:
|
|
140
|
+
logger.exception("Failed to add edge %s -> %s", edge.source, edge.target)
|
|
141
|
+
|
|
142
|
+
def clear(self) -> None:
|
|
143
|
+
try:
|
|
144
|
+
self._conn.execute("DELETE FROM edges")
|
|
145
|
+
self._conn.execute("DELETE FROM nodes")
|
|
146
|
+
self._conn.commit()
|
|
147
|
+
except sqlite3.Error:
|
|
148
|
+
logger.exception("Failed to clear graph")
|
|
149
|
+
|
|
150
|
+
def update_node_properties(self, node_id: str, properties: dict[str, Any]) -> None:
|
|
151
|
+
try:
|
|
152
|
+
row = self._conn.execute(
|
|
153
|
+
"SELECT data FROM nodes WHERE id = ?", (node_id,)
|
|
154
|
+
).fetchone()
|
|
155
|
+
if row is None:
|
|
156
|
+
return
|
|
157
|
+
node_data = json.loads(row[0])
|
|
158
|
+
props = node_data.get("properties", {})
|
|
159
|
+
props.update(properties)
|
|
160
|
+
node_data["properties"] = props
|
|
161
|
+
# Also update the label column in case callers rely on it,
|
|
162
|
+
# but the primary payload is the JSON blob.
|
|
163
|
+
node = GraphNode(**node_data)
|
|
164
|
+
self._conn.execute(
|
|
165
|
+
"UPDATE nodes SET data = ? WHERE id = ?",
|
|
166
|
+
(json.dumps(node.model_dump(mode="json")), node_id),
|
|
167
|
+
)
|
|
168
|
+
self._conn.commit()
|
|
169
|
+
except sqlite3.Error:
|
|
170
|
+
logger.exception("Failed to update properties for node %s", node_id)
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Queries — single item
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def get_node(self, node_id: str) -> GraphNode | None:
|
|
177
|
+
try:
|
|
178
|
+
row = self._conn.execute(
|
|
179
|
+
"SELECT data FROM nodes WHERE id = ?", (node_id,)
|
|
180
|
+
).fetchone()
|
|
181
|
+
if row is None:
|
|
182
|
+
return None
|
|
183
|
+
return _deserialize_node(row[0])
|
|
184
|
+
except sqlite3.Error:
|
|
185
|
+
logger.exception("Failed to get node %s", node_id)
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def has_node(self, node_id: str) -> bool:
|
|
189
|
+
try:
|
|
190
|
+
row = self._conn.execute(
|
|
191
|
+
"SELECT 1 FROM nodes WHERE id = ? LIMIT 1", (node_id,)
|
|
192
|
+
).fetchone()
|
|
193
|
+
return row is not None
|
|
194
|
+
except sqlite3.Error:
|
|
195
|
+
logger.exception("Failed to check node %s", node_id)
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
def get_edges_between(self, source: str, target: str) -> list[GraphEdge]:
|
|
199
|
+
try:
|
|
200
|
+
rows = self._conn.execute(
|
|
201
|
+
"SELECT data FROM edges WHERE source = ? AND target = ?",
|
|
202
|
+
(source, target),
|
|
203
|
+
).fetchall()
|
|
204
|
+
return [_deserialize_edge(r[0]) for r in rows]
|
|
205
|
+
except sqlite3.Error:
|
|
206
|
+
logger.exception("Failed to get edges between %s and %s", source, target)
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Queries — bulk
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def all_nodes(self) -> list[GraphNode]:
|
|
214
|
+
try:
|
|
215
|
+
rows = self._conn.execute("SELECT data FROM nodes").fetchall()
|
|
216
|
+
return [_deserialize_node(r[0]) for r in rows]
|
|
217
|
+
except sqlite3.Error:
|
|
218
|
+
logger.exception("Failed to fetch all nodes")
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
def all_edges(self) -> list[GraphEdge]:
|
|
222
|
+
try:
|
|
223
|
+
rows = self._conn.execute("SELECT data FROM edges").fetchall()
|
|
224
|
+
return [_deserialize_edge(r[0]) for r in rows]
|
|
225
|
+
except sqlite3.Error:
|
|
226
|
+
logger.exception("Failed to fetch all edges")
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
def nodes_by_kind(self, kind: NodeKind) -> list[GraphNode]:
|
|
230
|
+
try:
|
|
231
|
+
rows = self._conn.execute(
|
|
232
|
+
"SELECT data FROM nodes WHERE kind = ?", (kind.value,)
|
|
233
|
+
).fetchall()
|
|
234
|
+
return [_deserialize_node(r[0]) for r in rows]
|
|
235
|
+
except sqlite3.Error:
|
|
236
|
+
logger.exception("Failed to fetch nodes of kind %s", kind)
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
def edges_by_kind(self, kind: EdgeKind) -> list[GraphEdge]:
|
|
240
|
+
try:
|
|
241
|
+
rows = self._conn.execute(
|
|
242
|
+
"SELECT data FROM edges WHERE kind = ?", (kind.value,)
|
|
243
|
+
).fetchall()
|
|
244
|
+
return [_deserialize_edge(r[0]) for r in rows]
|
|
245
|
+
except sqlite3.Error:
|
|
246
|
+
logger.exception("Failed to fetch edges of kind %s", kind)
|
|
247
|
+
return []
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Graph traversal
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def neighbors(
|
|
254
|
+
self,
|
|
255
|
+
node_id: str,
|
|
256
|
+
edge_kinds: set[EdgeKind] | None = None,
|
|
257
|
+
direction: str = "both",
|
|
258
|
+
) -> list[str]:
|
|
259
|
+
result: set[str] = set()
|
|
260
|
+
try:
|
|
261
|
+
if direction in ("out", "both"):
|
|
262
|
+
if edge_kinds is not None:
|
|
263
|
+
placeholders = ",".join("?" for _ in edge_kinds)
|
|
264
|
+
rows = self._conn.execute(
|
|
265
|
+
f"SELECT target FROM edges WHERE source = ? AND kind IN ({placeholders})",
|
|
266
|
+
(node_id, *(k.value for k in edge_kinds)),
|
|
267
|
+
).fetchall()
|
|
268
|
+
else:
|
|
269
|
+
rows = self._conn.execute(
|
|
270
|
+
"SELECT target FROM edges WHERE source = ?", (node_id,)
|
|
271
|
+
).fetchall()
|
|
272
|
+
result.update(r[0] for r in rows)
|
|
273
|
+
|
|
274
|
+
if direction in ("in", "both"):
|
|
275
|
+
if edge_kinds is not None:
|
|
276
|
+
placeholders = ",".join("?" for _ in edge_kinds)
|
|
277
|
+
rows = self._conn.execute(
|
|
278
|
+
f"SELECT source FROM edges WHERE target = ? AND kind IN ({placeholders})",
|
|
279
|
+
(node_id, *(k.value for k in edge_kinds)),
|
|
280
|
+
).fetchall()
|
|
281
|
+
else:
|
|
282
|
+
rows = self._conn.execute(
|
|
283
|
+
"SELECT source FROM edges WHERE target = ?", (node_id,)
|
|
284
|
+
).fetchall()
|
|
285
|
+
result.update(r[0] for r in rows)
|
|
286
|
+
except sqlite3.Error:
|
|
287
|
+
logger.exception("Failed to find neighbors of %s", node_id)
|
|
288
|
+
return sorted(result)
|
|
289
|
+
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
# Advanced graph algorithms
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def find_cycles(self, limit: int = 100) -> list[list[str]]:
|
|
295
|
+
"""Detect cycles.
|
|
296
|
+
|
|
297
|
+
Attempts a recursive-CTE approach first for small/medium graphs.
|
|
298
|
+
Falls back to NetworkX ``simple_cycles`` for robustness.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
return self._find_cycles_networkx(limit)
|
|
302
|
+
except Exception:
|
|
303
|
+
logger.exception("Cycle detection failed")
|
|
304
|
+
return []
|
|
305
|
+
|
|
306
|
+
def _find_cycles_networkx(self, limit: int) -> list[list[str]]:
|
|
307
|
+
"""Load the graph into NetworkX and use ``simple_cycles``."""
|
|
308
|
+
g = self._to_networkx()
|
|
309
|
+
cycles: list[list[str]] = []
|
|
310
|
+
for cycle in nx.simple_cycles(g):
|
|
311
|
+
cycles.append(cycle)
|
|
312
|
+
if len(cycles) >= limit:
|
|
313
|
+
break
|
|
314
|
+
return cycles
|
|
315
|
+
|
|
316
|
+
def shortest_path(self, source: str, target: str) -> list[str] | None:
|
|
317
|
+
"""Find the shortest path between two nodes.
|
|
318
|
+
|
|
319
|
+
Uses a BFS via recursive CTE for simple cases, falling back to
|
|
320
|
+
NetworkX for correctness.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
return self._shortest_path_networkx(source, target)
|
|
324
|
+
except nx.NetworkXNoPath:
|
|
325
|
+
return None
|
|
326
|
+
except Exception:
|
|
327
|
+
logger.exception("Shortest-path computation failed")
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
def _shortest_path_networkx(self, source: str, target: str) -> list[str] | None:
|
|
331
|
+
g = self._to_networkx()
|
|
332
|
+
try:
|
|
333
|
+
return nx.shortest_path(g, source, target)
|
|
334
|
+
except nx.NetworkXNoPath:
|
|
335
|
+
return None
|
|
336
|
+
except nx.NodeNotFound:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
# ------------------------------------------------------------------
|
|
340
|
+
# Subgraph extraction
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def subgraph(self, node_ids: set[str]) -> SqliteGraphBackend:
|
|
344
|
+
"""Return a new in-memory SqliteGraphBackend containing only the
|
|
345
|
+
specified nodes and the edges between them."""
|
|
346
|
+
sub = SqliteGraphBackend(":memory:")
|
|
347
|
+
try:
|
|
348
|
+
if not node_ids:
|
|
349
|
+
return sub
|
|
350
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
351
|
+
ids = tuple(node_ids)
|
|
352
|
+
|
|
353
|
+
node_rows = self._conn.execute(
|
|
354
|
+
f"SELECT id, kind, label, data FROM nodes WHERE id IN ({placeholders})",
|
|
355
|
+
ids,
|
|
356
|
+
).fetchall()
|
|
357
|
+
if node_rows:
|
|
358
|
+
sub._conn.executemany(
|
|
359
|
+
"INSERT OR IGNORE INTO nodes VALUES (?, ?, ?, ?)",
|
|
360
|
+
[(r[0], r[1], r[2], r[3]) for r in node_rows],
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
edge_rows = self._conn.execute(
|
|
364
|
+
f"SELECT source, target, kind, data FROM edges "
|
|
365
|
+
f"WHERE source IN ({placeholders}) AND target IN ({placeholders})",
|
|
366
|
+
ids + ids,
|
|
367
|
+
).fetchall()
|
|
368
|
+
if edge_rows:
|
|
369
|
+
sub._conn.executemany(
|
|
370
|
+
"INSERT INTO edges (source, target, kind, data) VALUES (?, ?, ?, ?)",
|
|
371
|
+
[(r[0], r[1], r[2], r[3]) for r in edge_rows],
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
sub._conn.commit()
|
|
375
|
+
except sqlite3.Error:
|
|
376
|
+
logger.exception("Failed to extract subgraph")
|
|
377
|
+
return sub
|
|
378
|
+
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
# Lifecycle
|
|
381
|
+
# ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
def close(self) -> None:
|
|
384
|
+
try:
|
|
385
|
+
self._conn.commit()
|
|
386
|
+
self._conn.close()
|
|
387
|
+
except sqlite3.Error:
|
|
388
|
+
logger.exception("Error closing SQLite connection")
|
|
389
|
+
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
# Internal helpers
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def _to_networkx(self) -> nx.DiGraph:
|
|
395
|
+
"""Load the full graph into a NetworkX DiGraph for algorithm use."""
|
|
396
|
+
g = nx.DiGraph()
|
|
397
|
+
try:
|
|
398
|
+
for row in self._conn.execute("SELECT id FROM nodes").fetchall():
|
|
399
|
+
g.add_node(row[0])
|
|
400
|
+
for row in self._conn.execute(
|
|
401
|
+
"SELECT source, target FROM edges"
|
|
402
|
+
).fetchall():
|
|
403
|
+
g.add_edge(row[0], row[1])
|
|
404
|
+
except sqlite3.Error:
|
|
405
|
+
logger.exception("Failed to load graph into NetworkX")
|
|
406
|
+
return g
|