Skip to content

Commit cc140ed

Browse files
authored
Add DOT import (#377)
1 parent 590e3d7 commit cc140ed

9 files changed

Lines changed: 398 additions & 6 deletions

File tree

.github/workflows/build-and-test.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ jobs:
6969
- name: Install More Packages
7070
run: |
7171
ps aux | grep memgraph
72-
poetry install --all-extras
72+
poetry install -E arrow -E dgl -E docker -E dot
7373
poe install-pyg-cpu
7474
poe install-dgl
7575
poe install-tfgnn
@@ -148,8 +148,9 @@ jobs:
148148
poetry-version: ${{ env.POETRY_VERSION }}
149149
- name: Test project
150150
run: |
151-
poetry install --all-extras
151+
poetry install -E arrow -E dgl -E docker -E dot
152152
poe install-pyg-cpu
153+
poe install-dgl
153154
poe install-tfgnn
154155
export TF_USE_LEGACY_KERAS="1"
155156
poetry run pytest -vvv -m "not slow and not ubuntu and not docker"

.github/workflows/build-packages.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,10 @@ jobs:
122122
- name: Install wheel and dependencies
123123
run: |
124124
python -m pip install dist/*.whl
125-
poetry install --all-extras
125+
poetry install -E arrow -E dgl -E docker -E dot
126126
poe install-pyg-cpu
127127
poe install-dgl
128+
poe install-tfgnn
128129
129130
- name: Run Tests
130131
run: |

docs/how-to-guides/translators/import-python-graphs.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ hood](https://img.shields.io/static/v1?label=Related&message=Under%20the%20hood&
1010
In this guide you will learn how to:
1111

1212
- [**Import NetworkX graph into Memgraph**](#import-networkx-graph-into-memgraph)
13+
- [**Import DOT graph into Memgraph**](#import-dot-graph-into-memgraph)
1314
- [**Import PyG graph into Memgraph**](#import-pyg-graph-into-memgraph)
1415
- [**Import DGL graph into Memgraph**](#import-dgl-graph-into-memgraph)
1516
- [**Import TF-GNN graph into Memgraph**](#import-tf-gnn-graph-into-memgraph)
@@ -80,6 +81,37 @@ Click **Run Query** button to see the results.
8081

8182
The NetworkX node identification number maps to the `id` node property in Memgraph. The `labels` key is reserved for the node label in Memgraph, while the edge `type` key is reserved for the relationship type in Memgraph. If no `type` is defined, then the relationship will be of type `TO` in Memgraph. You can notice that the node with the property `name` Kata and property `id` 2 doesn't have a label. This happened because the node property key `labels` was not defined.
8283

84+
## Import DOT graph into Memgraph
85+
86+
You can import DOT files by using `GraphImporter` with `graph_type="NX"`. DOT parsing uses `pydot` and NetworkX under the hood.
87+
88+
### Prerequisites
89+
90+
Except for the [**general prerequisites**](#general-prerequisites), install DOT parsing support:
91+
92+
```bash
93+
pip install gqlalchemy[dot]
94+
```
95+
96+
### Create and run a Python script
97+
98+
Create a new Python script `dot-graph.py` with the following code:
99+
100+
```python
101+
from gqlalchemy.transformations.importing.graph_importer import GraphImporter
102+
103+
importer = GraphImporter(graph_type="NX")
104+
105+
# Import from a DOT file path.
106+
importer.translate_dot_file("graph.dot")
107+
108+
# Or import directly from DOT content.
109+
dot_data = 'digraph G { "A" [label="A"]; "B"; "A" -> "B" [fontsize="10"]; }'
110+
importer.translate_dot_data(dot_data)
111+
```
112+
113+
During DOT import, GQLAlchemy enriches parsed nodes and edges with DOT-specific metadata (for example `dot_type`, `attributes_json`, flattened `attributes_*` keys, `sequence`, and stable edge `id` values).
114+
83115
## Import PyG graph into Memgraph
84116

85117
### Prerequisites

docs/import-data.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ To import Parquet, ORC or IPC/Feather/Arrow file into Memgraph via GQLAlchemy, [
3333
## Python graphs - NetworkX, PyG or DGL graph
3434

3535
To import NetworkX, PyG or DGL graph into Memgraph via GQLAlchemy, [transform the source graph into Memgraph graph](how-to-guides/translators/import-python-graphs.md).
36+
DOT files and DOT strings are also supported through the NetworkX importer (`GraphImporter(graph_type="NX")`) with `translate_dot_file(...)` and `translate_dot_data(...)`.
3637

3738
## Kafka, RedPanda or Pulsar data stream
3839

docs/installation.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ additional import/export capabilities, use one of the following install options:
3636
```bash
3737
pip install gqlalchemy[arrow] # Support for the CSV, Parquet, ORC and IPC/Feather/Arrow formats
3838
pip install gqlalchemy[dgl] # DGL support (also includes torch)
39+
pip install gqlalchemy[dot] # DOT graph import support (pydot)
3940
pip install gqlalchemy[docker] # Docker support
4041
4142
pip install gqlalchemy[all] # All of the above
@@ -70,6 +71,7 @@ poetry install # No extras
7071
7172
poetry install -E arrow # Support for the CSV, Parquet, ORC and IPC/Feather/Arrow formats
7273
poetry install -E dgl # DGL support (also includes torch)
74+
poetry install -E dot # DOT graph import support (pydot)
7375
poetry install -E docker # Docker support
7476
7577
```

docs/reference/gqlalchemy/transformations/importing/graph_importer.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,27 @@ Gets cypher queries using the underlying translator and then inserts all queries
3131

3232
- `graph` - dgl, pytorch geometric or nx graph instance.
3333

34+
#### translate_dot_file
35+
36+
```python
37+
def translate_dot_file(path: str) -> None
38+
```
39+
40+
Parses a DOT file into a NetworkX graph and imports it to Memgraph. This method is available when `graph_type="NX"`.
41+
42+
**Arguments**:
43+
44+
- `path` - Path to a DOT file.
45+
46+
#### translate_dot_data
47+
48+
```python
49+
def translate_dot_data(dot_data: str) -> None
50+
```
51+
52+
Parses DOT content from a string into a NetworkX graph and imports it to Memgraph. This method is available when `graph_type="NX"`.
53+
54+
**Arguments**:
55+
56+
- `dot_data` - Raw DOT graph content.
57+

gqlalchemy/transformations/importing/graph_importer.py

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919
from gqlalchemy.exceptions import raise_if_not_imported
2020
import gqlalchemy.memgraph_constants as mg_consts
21+
import networkx as nx
22+
import json
23+
import re
24+
from collections import defaultdict
25+
from typing import Any, Dict
2126

2227
try:
2328
from gqlalchemy.transformations.translators.dgl_translator import DGLTranslator
@@ -31,6 +36,11 @@
3136
except ModuleNotFoundError:
3237
PyGTranslator = None
3338

39+
try:
40+
import pydot
41+
except ModuleNotFoundError:
42+
pydot = None
43+
3444

3545
class GraphImporter(Importer):
3646
"""Imports dgl, pyg or networkx graph representations to Memgraph.
@@ -69,8 +79,142 @@ def __init__(
6979
def translate(self, graph) -> None:
7080
"""Gets cypher queries using the underlying translator and then inserts all queries to Memgraph DB.
7181
Args:
72-
graph: dgl, pytorch geometric or nx graph instance.
82+
graph: dgl, pytorch geometric, dot or nx graph instance.
7383
"""
7484
memgraph = Memgraph()
7585
for query in self.translator.to_cypher_queries(graph):
7686
memgraph.execute(query)
87+
88+
def translate_dot_file(self, path: str) -> None:
89+
"""Parses a DOT file to a NetworkX graph and imports it to Memgraph."""
90+
self._raise_if_not_nx_importer()
91+
raise_if_not_imported(dependency=pydot, dependency_name="pydot")
92+
93+
pydot_graphs = pydot.graph_from_dot_file(path)
94+
if not pydot_graphs:
95+
raise ValueError("Unable to parse DOT file.")
96+
97+
graph = self._normalize_dot_graph(self._graph_from_pydot(pydot_graphs[0]))
98+
self.translate(graph)
99+
100+
def translate_dot_data(self, dot_data: str) -> None:
101+
"""Parses DOT content to a NetworkX graph and imports it to Memgraph."""
102+
self._raise_if_not_nx_importer()
103+
raise_if_not_imported(dependency=pydot, dependency_name="pydot")
104+
105+
pydot_graphs = pydot.graph_from_dot_data(dot_data)
106+
if not pydot_graphs:
107+
raise ValueError("Unable to parse DOT data.")
108+
109+
graph = self._normalize_dot_graph(self._graph_from_pydot(pydot_graphs[0]))
110+
self.translate(graph)
111+
112+
def _raise_if_not_nx_importer(self) -> None:
113+
if self.graph_type != GraphType.NX.name:
114+
raise ValueError("DOT import is supported only for NetworkX graph importer.")
115+
116+
def _normalize_dot_graph(self, graph: nx.Graph) -> nx.Graph:
117+
"""Enriches raw DOT graphs with stable, Cypher-friendly metadata."""
118+
normalized_graph = graph.__class__()
119+
edge_ids = defaultdict(int)
120+
sequence = 0
121+
122+
for node_id, data in graph.nodes(data=True):
123+
node_properties = self._normalize_dot_properties(data)
124+
node_properties["dot_type"] = "node"
125+
node_properties["display_name"] = node_properties.get("attributes_label", str(node_id))
126+
node_properties["source_graph"] = "dot"
127+
node_properties["sequence"] = sequence
128+
sequence += 1
129+
if "id" not in node_properties:
130+
node_properties["id"] = self._normalize_dot_value(node_id)
131+
normalized_graph.add_node(node_id, **node_properties)
132+
133+
for source, dest, data in graph.edges(data=True):
134+
edge_properties = self._normalize_dot_properties(data)
135+
edge_properties["dot_type"] = "edge"
136+
edge_properties["type"] = "DOT_EDGE"
137+
edge_key = f"{source}->{dest}"
138+
edge_index = edge_ids[edge_key]
139+
edge_ids[edge_key] += 1
140+
edge_id = edge_key if edge_index == 0 else f"{edge_key}#{edge_index}"
141+
edge_properties["id"] = self._normalize_dot_value(edge_id)
142+
edge_properties["points"] = [self._normalize_dot_value(source), self._normalize_dot_value(dest)]
143+
edge_properties["sequence"] = sequence
144+
sequence += 1
145+
normalized_graph.add_edge(source, dest, **edge_properties)
146+
147+
return normalized_graph
148+
149+
def _graph_from_pydot(self, dot_graph) -> nx.MultiDiGraph:
150+
"""Builds a MultiDiGraph from a pydot graph."""
151+
graph = nx.MultiDiGraph()
152+
153+
def _walk(current_graph) -> None:
154+
for node in current_graph.get_nodes():
155+
node_id = self._normalize_dot_value(node.get_name())
156+
# Ignore pydot/graphviz pseudo-nodes used for defaults.
157+
if node_id in {"", "node", "edge", "graph"}:
158+
continue
159+
160+
properties = self._normalize_dot_attributes(node.get_attributes() or {})
161+
if node_id in graph.nodes:
162+
graph.nodes[node_id].update(properties)
163+
else:
164+
graph.add_node(node_id, **properties)
165+
166+
for edge in current_graph.get_edges():
167+
source = self._normalize_dot_value(edge.get_source())
168+
dest = self._normalize_dot_value(edge.get_destination())
169+
if not source or not dest:
170+
continue
171+
172+
properties = self._normalize_dot_attributes(edge.get_attributes() or {})
173+
graph.add_edge(source, dest, **properties)
174+
175+
for subgraph in current_graph.get_subgraphs():
176+
_walk(subgraph)
177+
178+
_walk(dot_graph)
179+
180+
return graph
181+
182+
def _normalize_dot_properties(self, properties: Dict[str, Any]) -> Dict[str, Any]:
183+
normalized_attributes = self._normalize_dot_attributes(properties)
184+
normalized_properties: Dict[str, Any] = dict(normalized_attributes)
185+
normalized_properties["attributes_json"] = json.dumps(normalized_attributes, sort_keys=True)
186+
187+
for key, value in normalized_attributes.items():
188+
prefixed_key = f"attributes_{key}"
189+
while prefixed_key in normalized_properties:
190+
prefixed_key = f"{prefixed_key}_attribute"
191+
normalized_properties[prefixed_key] = value
192+
193+
return normalized_properties
194+
195+
def _normalize_dot_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
196+
normalized_attributes: Dict[str, Any] = {}
197+
for key, value in attributes.items():
198+
sanitized_key = self._sanitize_property_key(key)
199+
unique_key = self._resolve_key_collision(sanitized_key, normalized_attributes)
200+
normalized_attributes[unique_key] = self._normalize_dot_value(value)
201+
return normalized_attributes
202+
203+
@staticmethod
204+
def _resolve_key_collision(base_key: str, properties: Dict[str, Any]) -> str:
205+
unique_key = base_key
206+
suffix = 1
207+
while unique_key in properties:
208+
unique_key = f"{base_key}_{suffix}"
209+
suffix += 1
210+
return unique_key
211+
212+
@staticmethod
213+
def _normalize_dot_value(value: Any) -> Any:
214+
if isinstance(value, str):
215+
return value.strip().strip('"')
216+
return value
217+
218+
@staticmethod
219+
def _sanitize_property_key(key: str) -> str:
220+
return re.sub(r"[^0-9A-Za-z_]", "_", key)

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ exclude = '''
3434
python = "^3.9"
3535
pymgclient = "^1.5.1"
3636
networkx = ">=2.5.1,<4.0.0"
37+
pydot = { version = ">=1.4.2,<5.0.0", optional = true }
3738
pydantic = "^2.3.0"
3839
psutil = ">=5.9,<7.0"
3940
dacite = "^1.6.0"
@@ -58,7 +59,8 @@ priority = "explicit"
5859
arrow = ["pyarrow"]
5960
tfgnn = ["tensorflow-gnn", "tensorflow-macos", "tensorflow", "tf-keras"]
6061
dgl = ["torch", "dgl"]
61-
all = ["pyarrow", "torch", "dgl", "docker", "tensorflow-gnn"]
62+
dot = ["pydot"]
63+
all = ["pyarrow", "torch", "dgl", "docker", "tensorflow-gnn", "pydot"]
6264
torch_pyg = ["torch"]
6365
docker = ["docker"]
6466

@@ -75,7 +77,7 @@ pre-commit = ">=2.15.0,<4.0.0"
7577
[tool.poe.tasks]
7678
install-dgl = "pip install dgl==2.4.0 -f https://data.dgl.ai/wheels/torch-2.4/repo.html --no-build-isolation"
7779
install-pyg-cpu = "pip install 'torch>=2.4.0,<=2.5.0' torch-scatter torch-sparse torch-cluster torch-spline-conv torch-geometric -f https://data.pyg.org/whl/torch-2.4.0+cpu.html --no-build-isolation"
78-
install-tfgnn = "pip install tensorflow-gnn>=1.0.0,<2.0.0"
80+
install-tfgnn = "pip install 'tensorflow-gnn>=1.0.0,<2.0.0' 'tf-keras>=2.16,<3.0'"
7981

8082
[build-system]
8183
requires = ["poetry-core>=1.0.0", "setuptools>=78.1.1"]

0 commit comments

Comments
 (0)