fromscratch-pygraphdb 0.4.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.
- fromscratch_pygraphdb-0.4.0.dist-info/METADATA +145 -0
- fromscratch_pygraphdb-0.4.0.dist-info/RECORD +17 -0
- fromscratch_pygraphdb-0.4.0.dist-info/WHEEL +4 -0
- fromscratch_pygraphdb-0.4.0.dist-info/licenses/LICENSE +21 -0
- pygraphdb/__init__.py +72 -0
- pygraphdb/_version.py +24 -0
- pygraphdb/atomic.py +66 -0
- pygraphdb/edge.py +177 -0
- pygraphdb/errors.py +63 -0
- pygraphdb/graph.py +509 -0
- pygraphdb/matching.py +111 -0
- pygraphdb/node.py +149 -0
- pygraphdb/pathfinding.py +147 -0
- pygraphdb/persistence.py +129 -0
- pygraphdb/py.typed +0 -0
- pygraphdb/query.py +241 -0
- pygraphdb/traversal.py +105 -0
pygraphdb/node.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Node -- a single entity in a graph database.
|
|
2
|
+
|
|
3
|
+
Imagine a party where every guest wears a name tag. Each person (node)
|
|
4
|
+
has a unique ID (like their phone number), a label (like "Student" or
|
|
5
|
+
"Teacher"), and properties (like their name, age, or favorite color).
|
|
6
|
+
|
|
7
|
+
A node is the building block of a graph. By itself it's just a dot on
|
|
8
|
+
a whiteboard -- but connect it to other nodes with edges, and suddenly
|
|
9
|
+
you have a map of relationships!
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from pygraphdb.node import Node
|
|
13
|
+
>>> alice = Node(node_id="1", label="Person", properties={"name": "Alice", "age": 13})
|
|
14
|
+
>>> alice.label
|
|
15
|
+
'Person'
|
|
16
|
+
>>> alice.properties["name"]
|
|
17
|
+
'Alice'
|
|
18
|
+
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Node:
|
|
26
|
+
"""A single entity in the graph with an ID, label, and properties.
|
|
27
|
+
|
|
28
|
+
Think of a node like a person at a party:
|
|
29
|
+
- ``node_id`` is their unique wristband number (no two people share one,
|
|
30
|
+
and it can't be changed once it's on)
|
|
31
|
+
- ``label`` is the group they belong to ("Student", "Teacher", "Parent")
|
|
32
|
+
- ``properties`` is everything about them (name, age, hobbies)
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
node_id: A unique identifier for this node.
|
|
36
|
+
label: A category or type label (e.g. "Person", "City").
|
|
37
|
+
properties: A dictionary of key-value attributes.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
node_id: str
|
|
42
|
+
label: str = ""
|
|
43
|
+
properties: dict[str, object] = field(default_factory=lambda: {})
|
|
44
|
+
|
|
45
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
46
|
+
"""Set an attribute, except ``node_id`` once it has been set.
|
|
47
|
+
|
|
48
|
+
The graph files every node under its id. Changing the id afterwards
|
|
49
|
+
would leave the node filed under the old one, like swapping your
|
|
50
|
+
wristband after the guest list was written -- so it isn't allowed.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
AttributeError: If ``node_id`` is set a second time.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
if name == "node_id" and "node_id" in self.__dict__:
|
|
57
|
+
msg = "a node's node_id can't be changed: it files the node in the graph"
|
|
58
|
+
raise AttributeError(msg)
|
|
59
|
+
super().__setattr__(name, value)
|
|
60
|
+
|
|
61
|
+
def has_property(self, key: str) -> bool:
|
|
62
|
+
"""Check whether this node has a given property key.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key: The property name to look for.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if the property exists, False otherwise.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
return key in self.properties
|
|
72
|
+
|
|
73
|
+
def get_property(self, key: str, default: object = None) -> object:
|
|
74
|
+
"""Get a property value by key, with an optional default.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
key: The property name to retrieve.
|
|
78
|
+
default: Value to return if the key is missing.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The property value, or the default.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
return self.properties.get(key, default)
|
|
85
|
+
|
|
86
|
+
def set_property(self, key: str, value: object) -> None:
|
|
87
|
+
"""Set a property on this node.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
key: The property name.
|
|
91
|
+
value: The property value.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
self.properties[key] = value
|
|
95
|
+
|
|
96
|
+
def remove_property(self, key: str) -> object:
|
|
97
|
+
"""Remove and return a property from this node.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
key: The property name to remove.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The removed value.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
KeyError: If the property does not exist.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
return self.properties.pop(key)
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, object]:
|
|
112
|
+
"""Write everything about this node onto an index card.
|
|
113
|
+
|
|
114
|
+
Like copying a party guest's wristband number, group, and details
|
|
115
|
+
onto a card you can file away -- turns the node into a plain
|
|
116
|
+
dictionary you can save to a file.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A dictionary representation of this node.
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
return {
|
|
123
|
+
"node_id": self.node_id,
|
|
124
|
+
"label": self.label,
|
|
125
|
+
"properties": dict(self.properties),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def from_dict(cls, data: dict[str, object]) -> Node:
|
|
130
|
+
"""Rebuild a node from the index card that to_dict wrote.
|
|
131
|
+
|
|
132
|
+
Like reading a filed-away card and bringing that party guest back --
|
|
133
|
+
turns a plain dictionary back into a real Node.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
data: A dictionary with node_id, label, and properties keys.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A new Node instance.
|
|
140
|
+
|
|
141
|
+
"""
|
|
142
|
+
node_id = str(data.get("node_id", ""))
|
|
143
|
+
label = str(data.get("label", ""))
|
|
144
|
+
raw_props = data.get("properties")
|
|
145
|
+
props: dict[str, object] = {}
|
|
146
|
+
if isinstance(raw_props, dict):
|
|
147
|
+
for k, v in raw_props.items(): # pyright: ignore[reportUnknownVariableType]
|
|
148
|
+
props[str(k)] = v # pyright: ignore[reportUnknownArgumentType]
|
|
149
|
+
return cls(node_id=node_id, label=label, properties=props)
|
pygraphdb/pathfinding.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Shortest path finding -- like GPS directions for your graph.
|
|
2
|
+
|
|
3
|
+
When you ask Google Maps for directions, it finds the route with the
|
|
4
|
+
fewest turns (or shortest distance). Our pathfinding does the same
|
|
5
|
+
thing for graphs -- it finds the path with the fewest edges between
|
|
6
|
+
two nodes.
|
|
7
|
+
|
|
8
|
+
We use BFS (breadth-first search) because it naturally finds the
|
|
9
|
+
shortest path in an unweighted graph. It explores nodes layer by
|
|
10
|
+
layer, so the first time it reaches the destination, that path is
|
|
11
|
+
guaranteed to be the shortest!
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> from pygraphdb.graph import Graph
|
|
15
|
+
>>> from pygraphdb.pathfinding import shortest_path
|
|
16
|
+
>>> g = Graph()
|
|
17
|
+
>>> for nid in ["a", "b", "c", "d"]:
|
|
18
|
+
... g.add_node(nid)
|
|
19
|
+
>>> g.add_edge("e1", "a", "b")
|
|
20
|
+
>>> g.add_edge("e2", "b", "c")
|
|
21
|
+
>>> g.add_edge("e3", "a", "d")
|
|
22
|
+
>>> g.add_edge("e4", "d", "c")
|
|
23
|
+
>>> path = shortest_path(g, "a", "c")
|
|
24
|
+
>>> [n.node_id for n in path]
|
|
25
|
+
['a', 'b', 'c']
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from collections import deque
|
|
30
|
+
|
|
31
|
+
from pygraphdb.graph import Graph
|
|
32
|
+
from pygraphdb.node import Node
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def shortest_path(graph: Graph, start_id: str, end_id: str) -> list[Node]:
|
|
36
|
+
"""Find the shortest path between two nodes using BFS.
|
|
37
|
+
|
|
38
|
+
Like asking for GPS directions: find the route with the fewest
|
|
39
|
+
stops between your starting point and your destination.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
graph: The graph to search.
|
|
43
|
+
start_id: The ID of the starting node.
|
|
44
|
+
end_id: The ID of the destination node.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A list of nodes forming the shortest path (including start and end).
|
|
48
|
+
An empty list if no path exists.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
NodeNotFoundError: If start or end node does not exist.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
graph.get_node(start_id) # Validate existence
|
|
55
|
+
graph.get_node(end_id) # Validate existence
|
|
56
|
+
|
|
57
|
+
if start_id == end_id:
|
|
58
|
+
return [graph.get_node(start_id)]
|
|
59
|
+
|
|
60
|
+
visited: set[str] = {start_id}
|
|
61
|
+
# Each queue entry is (current_node_id, path_so_far)
|
|
62
|
+
queue: deque[tuple[str, list[str]]] = deque([(start_id, [start_id])])
|
|
63
|
+
|
|
64
|
+
while queue:
|
|
65
|
+
current_id, path = queue.popleft()
|
|
66
|
+
for edge in graph.get_outgoing_edges(current_id):
|
|
67
|
+
neighbor_id = edge.target_id
|
|
68
|
+
if neighbor_id == end_id:
|
|
69
|
+
full_path = [*path, neighbor_id]
|
|
70
|
+
return [graph.get_node(nid) for nid in full_path]
|
|
71
|
+
if neighbor_id not in visited:
|
|
72
|
+
visited.add(neighbor_id)
|
|
73
|
+
queue.append((neighbor_id, [*path, neighbor_id]))
|
|
74
|
+
|
|
75
|
+
return []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def all_paths(
|
|
79
|
+
graph: Graph,
|
|
80
|
+
start_id: str,
|
|
81
|
+
end_id: str,
|
|
82
|
+
*,
|
|
83
|
+
max_depth: int = 10,
|
|
84
|
+
) -> list[list[Node]]:
|
|
85
|
+
"""Find all paths between two nodes up to a maximum depth.
|
|
86
|
+
|
|
87
|
+
Like finding every possible route between your house and school,
|
|
88
|
+
not just the shortest one.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
graph: The graph to search.
|
|
92
|
+
start_id: The ID of the starting node.
|
|
93
|
+
end_id: The ID of the destination node.
|
|
94
|
+
max_depth: Maximum path length to consider.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
A list of paths, where each path is a list of nodes.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
NodeNotFoundError: If start or end node does not exist.
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
graph.get_node(start_id) # Validate existence
|
|
104
|
+
graph.get_node(end_id) # Validate existence
|
|
105
|
+
|
|
106
|
+
results: list[list[str]] = []
|
|
107
|
+
_all_paths_helper(graph, start_id, end_id, [start_id], set(), results, max_depth)
|
|
108
|
+
return [[graph.get_node(nid) for nid in path] for path in results]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _all_paths_helper(
|
|
112
|
+
graph: Graph,
|
|
113
|
+
current_id: str,
|
|
114
|
+
end_id: str,
|
|
115
|
+
current_path: list[str],
|
|
116
|
+
visited: set[str],
|
|
117
|
+
results: list[list[str]],
|
|
118
|
+
max_depth: int,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Recursive helper for finding all paths.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
graph: The graph to search.
|
|
124
|
+
current_id: Current position in the traversal.
|
|
125
|
+
end_id: The destination node ID.
|
|
126
|
+
current_path: Path taken so far.
|
|
127
|
+
visited: Set of visited node IDs.
|
|
128
|
+
results: Accumulator for found paths.
|
|
129
|
+
max_depth: Maximum remaining depth.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
if current_id == end_id:
|
|
133
|
+
results.append(list(current_path))
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
if max_depth <= 0:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
visited.add(current_id)
|
|
140
|
+
for edge in graph.get_outgoing_edges(current_id):
|
|
141
|
+
if edge.target_id not in visited:
|
|
142
|
+
current_path.append(edge.target_id)
|
|
143
|
+
_all_paths_helper(
|
|
144
|
+
graph, edge.target_id, end_id, current_path, visited, results, max_depth - 1
|
|
145
|
+
)
|
|
146
|
+
current_path.pop()
|
|
147
|
+
visited.discard(current_id)
|
pygraphdb/persistence.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Persistence -- save and load graphs to/from JSON files.
|
|
2
|
+
|
|
3
|
+
Think of persistence like saving a video game. When you're done
|
|
4
|
+
playing, you save your progress to a file. Next time you start
|
|
5
|
+
the game, you load that file and pick up exactly where you left off.
|
|
6
|
+
|
|
7
|
+
Our graph database works the same way: you can save your entire
|
|
8
|
+
graph (all nodes and edges) to a JSON file, and later load it
|
|
9
|
+
back into memory to continue working with it.
|
|
10
|
+
|
|
11
|
+
JSON is a text format that both humans and computers can read --
|
|
12
|
+
it looks like a nicely organized list with curly braces and colons.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from pygraphdb.graph import Graph
|
|
16
|
+
>>> from pygraphdb.persistence import save_graph, load_graph
|
|
17
|
+
>>> g = Graph()
|
|
18
|
+
>>> g.add_node("alice", label="Person")
|
|
19
|
+
>>> save_graph(g, "/tmp/my_graph.json")
|
|
20
|
+
>>> g2 = load_graph("/tmp/my_graph.json")
|
|
21
|
+
>>> g2.get_node("alice").label
|
|
22
|
+
'Person'
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import cast
|
|
29
|
+
|
|
30
|
+
from pygraphdb.atomic import atomic_write_text
|
|
31
|
+
from pygraphdb.errors import GraphError, PersistenceError
|
|
32
|
+
from pygraphdb.graph import Graph
|
|
33
|
+
|
|
34
|
+
# Every real save file has these top-level keys; anything else isn't one of ours.
|
|
35
|
+
_REQUIRED_KEYS = frozenset({"nodes", "edges"})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_graph(graph: Graph, file_path: str | Path) -> None:
|
|
39
|
+
"""Save a graph to a JSON file.
|
|
40
|
+
|
|
41
|
+
Like saving your game progress -- writes all nodes and edges
|
|
42
|
+
to a file so you can load them later.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
graph: The graph to save.
|
|
46
|
+
file_path: The path to write the JSON file to.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
path = Path(file_path)
|
|
50
|
+
data = graph.to_dict()
|
|
51
|
+
atomic_write_text(path, json.dumps(data, indent=2, default=str))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_graph(file_path: str | Path) -> Graph:
|
|
55
|
+
"""Load a graph from a JSON file.
|
|
56
|
+
|
|
57
|
+
Like loading a saved game -- reads nodes and edges from a file
|
|
58
|
+
and rebuilds the graph in memory.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
file_path: The path to read the JSON file from.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A new Graph populated with the saved data.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
PersistenceError: If the file is missing, unreadable, not valid JSON,
|
|
68
|
+
or not a valid save file.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
path = Path(file_path)
|
|
72
|
+
try:
|
|
73
|
+
raw = path.read_text(encoding="utf-8")
|
|
74
|
+
# A file that isn't UTF-8 text (an image, a zip, a corrupted save) fails
|
|
75
|
+
# while decoding, not while reading, so it needs catching too.
|
|
76
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
77
|
+
msg = f"Could not read {path}: {exc}"
|
|
78
|
+
raise PersistenceError(msg) from exc
|
|
79
|
+
try:
|
|
80
|
+
data: object = json.loads(raw)
|
|
81
|
+
# JSONDecodeError is a kind of ValueError; a plain ValueError also means a
|
|
82
|
+
# number too long for Python to read, and RecursionError means lists or
|
|
83
|
+
# objects nested too deep. Each one is a file we can't load.
|
|
84
|
+
except (ValueError, RecursionError) as exc:
|
|
85
|
+
msg = f"Invalid JSON in {path}: {exc}"
|
|
86
|
+
raise PersistenceError(msg) from exc
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
msg = f"{path} is not a valid save file: the top level must be an object"
|
|
89
|
+
raise PersistenceError(msg)
|
|
90
|
+
saved: dict[str, object] = {str(k): v for k, v in cast("dict[object, object]", data).items()}
|
|
91
|
+
missing = _REQUIRED_KEYS - saved.keys()
|
|
92
|
+
if missing:
|
|
93
|
+
msg = f"{path} is not a valid save file: missing {sorted(missing)}"
|
|
94
|
+
raise PersistenceError(msg)
|
|
95
|
+
try:
|
|
96
|
+
return Graph.from_dict(saved)
|
|
97
|
+
# GraphError covers an edge whose end isn't one of the saved nodes, and two
|
|
98
|
+
# nodes or edges sharing an id: files save_graph never writes, so they're
|
|
99
|
+
# refused like any other bad file.
|
|
100
|
+
except (GraphError, KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
101
|
+
msg = f"{path} is not a valid save file: {exc}"
|
|
102
|
+
raise PersistenceError(msg) from exc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def graph_to_json(graph: Graph) -> str:
|
|
106
|
+
"""Serialize a graph to a JSON string (without writing to disk).
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
graph: The graph to serialize.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A JSON string representation of the graph.
|
|
113
|
+
|
|
114
|
+
"""
|
|
115
|
+
return json.dumps(graph.to_dict(), indent=2, default=str)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def graph_from_json(json_string: str) -> Graph:
|
|
119
|
+
"""Deserialize a graph from a JSON string.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
json_string: A JSON string representing a graph.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A new Graph populated from the JSON data.
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
data: dict[str, object] = json.loads(json_string)
|
|
129
|
+
return Graph.from_dict(data)
|
pygraphdb/py.typed
ADDED
|
File without changes
|
pygraphdb/query.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Simple Cypher-like query language parser and executor.
|
|
2
|
+
|
|
3
|
+
Real graph databases like Neo4j use a language called Cypher to ask
|
|
4
|
+
questions about the graph. It's like SQL but for graphs! Our version
|
|
5
|
+
is simplified but follows the same idea.
|
|
6
|
+
|
|
7
|
+
Think of it like asking questions in a specific format:
|
|
8
|
+
- "MATCH" means "find me..."
|
|
9
|
+
- "(n:Person)" means "a node labeled Person, call it n"
|
|
10
|
+
- "-[:KNOWS]->" means "connected by a KNOWS relationship"
|
|
11
|
+
- "WHERE n.name = 'Alice'" means "but only if the name is Alice"
|
|
12
|
+
- "RETURN m" means "give me back the thing called m"
|
|
13
|
+
|
|
14
|
+
Supported query format:
|
|
15
|
+
MATCH (n:Label)-[:REL_TYPE]->(m) WHERE n.prop = "value" RETURN m
|
|
16
|
+
MATCH (n:Label) RETURN n
|
|
17
|
+
MATCH (n:Label) WHERE n.prop = "value" RETURN n
|
|
18
|
+
|
|
19
|
+
Example:
|
|
20
|
+
>>> from pygraphdb.graph import Graph
|
|
21
|
+
>>> from pygraphdb.query import execute_query
|
|
22
|
+
>>> g = Graph()
|
|
23
|
+
>>> g.add_node("1", label="Person", properties={"name": "Alice"})
|
|
24
|
+
>>> g.add_node("2", label="Person", properties={"name": "Bob"})
|
|
25
|
+
>>> g.add_edge("e1", "1", "2", rel_type="KNOWS")
|
|
26
|
+
>>> results = execute_query(g, 'MATCH (n:Person) WHERE n.name = "Alice" RETURN n')
|
|
27
|
+
>>> len(results)
|
|
28
|
+
1
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
|
|
34
|
+
from pygraphdb.errors import QuerySyntaxError
|
|
35
|
+
from pygraphdb.graph import Graph
|
|
36
|
+
from pygraphdb.node import Node
|
|
37
|
+
|
|
38
|
+
# Pattern for: MATCH (var:Label)-[:REL]->(var2) WHERE var.prop = "val" RETURN var2
|
|
39
|
+
_PATTERN_WITH_EDGE = re.compile(
|
|
40
|
+
r"MATCH\s+\((\w+):(\w+)\)" # (n:Label)
|
|
41
|
+
r"\s*-\[:(\w+)\]->\s*" # -[:REL]->
|
|
42
|
+
r"\((\w+)\)" # (m)
|
|
43
|
+
r"(?:\s+WHERE\s+(.+?))?" # optional WHERE clause
|
|
44
|
+
r"\s+RETURN\s+(\w+)", # RETURN var
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Pattern for: MATCH (var:Label) WHERE var.prop = "val" RETURN var
|
|
49
|
+
_PATTERN_NODE_ONLY = re.compile(
|
|
50
|
+
r"MATCH\s+\((\w+):(\w+)\)" # (n:Label)
|
|
51
|
+
r"(?:\s+WHERE\s+(.+?))?" # optional WHERE clause
|
|
52
|
+
r"\s+RETURN\s+(\w+)", # RETURN var
|
|
53
|
+
re.IGNORECASE,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Pattern for WHERE conditions: var.prop = "value" or var.prop = 'value'
|
|
57
|
+
_WHERE_CONDITION = re.compile(
|
|
58
|
+
r"(\w+)\.(\w+)\s*=\s*[\"']([^\"']*)[\"']",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def execute_query(graph: Graph, query: str) -> list[Node]:
|
|
63
|
+
"""Execute a simple Cypher-like query against the graph.
|
|
64
|
+
|
|
65
|
+
Parse and run a query string to find matching nodes.
|
|
66
|
+
|
|
67
|
+
Supported patterns:
|
|
68
|
+
- ``MATCH (n:Label) RETURN n``
|
|
69
|
+
- ``MATCH (n:Label) WHERE n.prop = "value" RETURN n``
|
|
70
|
+
- ``MATCH (n:Label)-[:REL]->(m) RETURN m``
|
|
71
|
+
- ``MATCH (n:Label)-[:REL]->(m) WHERE n.prop = "value" RETURN m``
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
graph: The graph to query.
|
|
75
|
+
query: A Cypher-like query string.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A list of nodes matching the query.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
QuerySyntaxError: If the query cannot be parsed.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
query = query.strip()
|
|
85
|
+
|
|
86
|
+
# Try edge pattern first (more specific)
|
|
87
|
+
match = _PATTERN_WITH_EDGE.match(query)
|
|
88
|
+
if match:
|
|
89
|
+
return _execute_edge_query(graph, match)
|
|
90
|
+
|
|
91
|
+
# Try node-only pattern
|
|
92
|
+
match = _PATTERN_NODE_ONLY.match(query)
|
|
93
|
+
if match:
|
|
94
|
+
return _execute_node_query(graph, match)
|
|
95
|
+
|
|
96
|
+
msg = f"Cannot parse query: {query}"
|
|
97
|
+
raise QuerySyntaxError(msg)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _execute_node_query(graph: Graph, match: re.Match[str]) -> list[Node]:
|
|
101
|
+
"""Execute a node-only query (no edge pattern).
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
graph: The graph to query.
|
|
105
|
+
match: The regex match object.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A list of matching nodes.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
var_name = match.group(1)
|
|
112
|
+
label = match.group(2)
|
|
113
|
+
where_clause = match.group(3)
|
|
114
|
+
return_var = match.group(4)
|
|
115
|
+
|
|
116
|
+
if return_var != var_name:
|
|
117
|
+
msg = f"RETURN variable '{return_var}' not defined in MATCH"
|
|
118
|
+
raise QuerySyntaxError(msg)
|
|
119
|
+
|
|
120
|
+
conditions = _parse_conditions(where_clause, {var_name})
|
|
121
|
+
|
|
122
|
+
results: list[Node] = []
|
|
123
|
+
for node in graph.get_all_nodes():
|
|
124
|
+
if node.label != label:
|
|
125
|
+
continue
|
|
126
|
+
if not _node_matches(node, var_name, conditions):
|
|
127
|
+
continue
|
|
128
|
+
results.append(node)
|
|
129
|
+
return results
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _execute_edge_query(graph: Graph, match: re.Match[str]) -> list[Node]:
|
|
133
|
+
"""Execute a query with an edge pattern.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
graph: The graph to query.
|
|
137
|
+
match: The regex match object.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
A list of matching nodes.
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
source_var = match.group(1)
|
|
144
|
+
source_label = match.group(2)
|
|
145
|
+
rel_type = match.group(3)
|
|
146
|
+
target_var = match.group(4)
|
|
147
|
+
where_clause = match.group(5)
|
|
148
|
+
return_var = match.group(6)
|
|
149
|
+
|
|
150
|
+
if return_var not in (source_var, target_var):
|
|
151
|
+
msg = f"RETURN variable '{return_var}' not defined in MATCH"
|
|
152
|
+
raise QuerySyntaxError(msg)
|
|
153
|
+
|
|
154
|
+
# WHERE conditions may reference either the source or the target variable.
|
|
155
|
+
conditions = _parse_conditions(where_clause, {source_var, target_var})
|
|
156
|
+
|
|
157
|
+
results: list[Node] = []
|
|
158
|
+
seen_ids: set[str] = set()
|
|
159
|
+
|
|
160
|
+
for node in graph.get_all_nodes():
|
|
161
|
+
if node.label != source_label:
|
|
162
|
+
continue
|
|
163
|
+
if not _node_matches(node, source_var, conditions):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
for edge in graph.get_outgoing_edges(node.node_id):
|
|
167
|
+
if edge.rel_type != rel_type:
|
|
168
|
+
continue
|
|
169
|
+
target = graph.get_node(edge.target_id)
|
|
170
|
+
if not _node_matches(target, target_var, conditions):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
if return_var == source_var:
|
|
174
|
+
# One matching edge is enough to keep the source node.
|
|
175
|
+
if node.node_id not in seen_ids:
|
|
176
|
+
seen_ids.add(node.node_id)
|
|
177
|
+
results.append(node)
|
|
178
|
+
break
|
|
179
|
+
if target.node_id not in seen_ids:
|
|
180
|
+
seen_ids.add(target.node_id)
|
|
181
|
+
results.append(target)
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_conditions(
|
|
187
|
+
where_clause: str | None,
|
|
188
|
+
valid_vars: set[str],
|
|
189
|
+
) -> list[tuple[str, str, str]]:
|
|
190
|
+
"""Turn a raw WHERE clause into a list of (var, prop, value) conditions.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
where_clause: The raw WHERE clause string, or None if there was no WHERE.
|
|
194
|
+
valid_vars: The variable names the query actually defines.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
A list of parsed conditions (empty if there was no WHERE clause).
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
QuerySyntaxError: If a WHERE clause is present but nothing parses
|
|
201
|
+
(for example an unquoted value), or a condition refers to a
|
|
202
|
+
variable the query never defined.
|
|
203
|
+
|
|
204
|
+
"""
|
|
205
|
+
if where_clause is None:
|
|
206
|
+
return []
|
|
207
|
+
conditions: list[tuple[str, str, str]] = _WHERE_CONDITION.findall(where_clause)
|
|
208
|
+
if not conditions:
|
|
209
|
+
msg = (
|
|
210
|
+
f"Cannot understand WHERE clause: {where_clause!r} "
|
|
211
|
+
'(values must be quoted, e.g. n.name = "Alice")'
|
|
212
|
+
)
|
|
213
|
+
raise QuerySyntaxError(msg)
|
|
214
|
+
for cond_var, _prop_name, _prop_value in conditions:
|
|
215
|
+
if cond_var not in valid_vars:
|
|
216
|
+
msg = f"WHERE refers to unknown variable '{cond_var}'"
|
|
217
|
+
raise QuerySyntaxError(msg)
|
|
218
|
+
return conditions
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _node_matches(node: Node, var_name: str, conditions: list[tuple[str, str, str]]) -> bool:
|
|
222
|
+
"""Check whether a node satisfies the conditions aimed at its variable.
|
|
223
|
+
|
|
224
|
+
Only conditions whose variable equals ``var_name`` apply here; conditions
|
|
225
|
+
for other variables are checked against their own nodes elsewhere.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
node: The node to test.
|
|
229
|
+
var_name: The variable this node is bound to.
|
|
230
|
+
conditions: All parsed WHERE conditions.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if the node satisfies every condition aimed at ``var_name``.
|
|
234
|
+
|
|
235
|
+
"""
|
|
236
|
+
for cond_var, prop_name, prop_value in conditions:
|
|
237
|
+
if cond_var != var_name:
|
|
238
|
+
continue
|
|
239
|
+
if str(node.get_property(prop_name)) != prop_value:
|
|
240
|
+
return False
|
|
241
|
+
return True
|