pylpg 0.1.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.
pylpg/__init__.py ADDED
File without changes
File without changes
pylpg/backend/base.py ADDED
@@ -0,0 +1,71 @@
1
+ """Abstract base class for database backends."""
2
+
3
+ import abc
4
+ import typing
5
+
6
+ if typing.TYPE_CHECKING:
7
+ import pylpg.node
8
+ import pylpg.relationship
9
+
10
+
11
+ class Backend(abc.ABC):
12
+ """Abstract base class that all database backends must implement.
13
+
14
+ Backends handle all direct database operations: CRUD for nodes and
15
+ relationships, batch operations, traversal, and query execution.
16
+ """
17
+
18
+ @abc.abstractmethod
19
+ def execute_query(
20
+ self, cypher: str, parameters: dict[str, typing.Any] | None = None
21
+ ) -> list[dict[str, typing.Any]]: ...
22
+
23
+ @abc.abstractmethod
24
+ def is_node(self, value: typing.Any) -> bool: ...
25
+
26
+ @abc.abstractmethod
27
+ def deserialize_node(self, record: typing.Any) -> dict[str, typing.Any]: ...
28
+
29
+ @abc.abstractmethod
30
+ def create_node(self, node: "pylpg.node.Node") -> dict[str, typing.Any]: ...
31
+
32
+ @abc.abstractmethod
33
+ def update_node(self, node: "pylpg.node.Node") -> dict[str, typing.Any]: ...
34
+
35
+ @abc.abstractmethod
36
+ def delete_node(self, node: "pylpg.node.Node") -> None: ...
37
+
38
+ @abc.abstractmethod
39
+ def create_relationship(
40
+ self, relationship: "pylpg.relationship.Relationship"
41
+ ) -> dict[str, typing.Any]: ...
42
+
43
+ @abc.abstractmethod
44
+ def update_relationship(
45
+ self, relationship: "pylpg.relationship.Relationship"
46
+ ) -> dict[str, typing.Any]: ...
47
+
48
+ @abc.abstractmethod
49
+ def delete_relationship(
50
+ self, relationship: "pylpg.relationship.Relationship"
51
+ ) -> None: ...
52
+
53
+ @abc.abstractmethod
54
+ def save_batch(
55
+ self,
56
+ items: "list[pylpg.node.Node | pylpg.relationship.Relationship]",
57
+ ) -> None: ...
58
+
59
+ @abc.abstractmethod
60
+ def traverse(
61
+ self,
62
+ node: "pylpg.node.Node",
63
+ relationship_type: str,
64
+ direction: "pylpg.relationship.Direction",
65
+ ) -> list[dict[str, typing.Any]]: ...
66
+
67
+ @abc.abstractmethod
68
+ def delete_all(self) -> None: ...
69
+
70
+ @abc.abstractmethod
71
+ def close(self) -> None: ...
@@ -0,0 +1,151 @@
1
+ """FalkorDB backend using the FalkorDB Python driver."""
2
+
3
+ import typing
4
+
5
+ import falkordb
6
+ import falkordb.node
7
+
8
+ import pylpg.backend.base
9
+ import pylpg.cypher
10
+ import pylpg.node
11
+ import pylpg.relationship
12
+
13
+
14
+ class FalkorDBBackend(pylpg.backend.base.Backend):
15
+ """Backend for FalkorDB server instances.
16
+
17
+ Uses individual queries for batch operations, which is optimal
18
+ for FalkorDB's in-memory architecture.
19
+ """
20
+
21
+ _database_id_func_name = "ID"
22
+
23
+ def __init__(
24
+ self,
25
+ hostname: str = "localhost",
26
+ port: int = 6379,
27
+ database: str = "default",
28
+ username: str | None = None,
29
+ password: str | None = None,
30
+ ) -> None:
31
+ self._db = falkordb.FalkorDB(
32
+ host=hostname, port=port, username=username, password=password
33
+ )
34
+ self._graph = self._db.select_graph(database)
35
+
36
+ def execute_query(
37
+ self, cypher: str, parameters: dict[str, typing.Any] | None = None
38
+ ) -> list[dict[str, typing.Any]]:
39
+ result = self._graph.query(cypher, params=parameters or {})
40
+ if result.result_set:
41
+ header = [header_item[1] for header_item in result.header]
42
+ return [dict(zip(header, row)) for row in result.result_set]
43
+ return []
44
+
45
+ def is_node(self, value: typing.Any) -> bool:
46
+ return isinstance(value, falkordb.node.Node)
47
+
48
+ def deserialize_node(self, record: typing.Any) -> dict[str, typing.Any]:
49
+ properties = dict(record.properties)
50
+ properties["_labels"] = frozenset(record.labels)
51
+ properties["_database_id"] = record.id
52
+ return properties
53
+
54
+ def create_node(self, node: pylpg.node.Node) -> dict[str, typing.Any]:
55
+ cypher, params = pylpg.cypher.build_create_node_query(
56
+ node=node, database_id_func_name=self._database_id_func_name
57
+ )
58
+ result = self.execute_query(cypher, params)
59
+ return result[0] if result else {}
60
+
61
+ def update_node(self, node: pylpg.node.Node) -> dict[str, typing.Any]:
62
+ cypher, params = pylpg.cypher.build_update_node_query(
63
+ node=node, database_id_func_name=self._database_id_func_name
64
+ )
65
+ result = self.execute_query(cypher, params)
66
+ return result[0] if result else {}
67
+
68
+ def delete_node(self, node: pylpg.node.Node) -> None:
69
+ cypher, params = pylpg.cypher.build_delete_node_query(
70
+ node=node, database_id_func_name=self._database_id_func_name
71
+ )
72
+ self.execute_query(cypher, params)
73
+
74
+ def create_relationship(
75
+ self, relationship: pylpg.relationship.Relationship
76
+ ) -> dict[str, typing.Any]:
77
+ cypher, params = pylpg.cypher.build_create_relationship_query(
78
+ relationship=relationship,
79
+ database_id_func_name=self._database_id_func_name,
80
+ )
81
+ result = self.execute_query(cypher, params)
82
+ return result[0] if result else {}
83
+
84
+ def update_relationship(
85
+ self, relationship: pylpg.relationship.Relationship
86
+ ) -> dict[str, typing.Any]:
87
+ cypher, params = pylpg.cypher.build_update_relationship_query(
88
+ relationship=relationship,
89
+ database_id_func_name=self._database_id_func_name,
90
+ )
91
+ result = self.execute_query(cypher, params)
92
+ return result[0] if result else {}
93
+
94
+ def delete_relationship(
95
+ self, relationship: pylpg.relationship.Relationship
96
+ ) -> None:
97
+ cypher, params = pylpg.cypher.build_delete_relationship_query(
98
+ relationship=relationship,
99
+ database_id_func_name=self._database_id_func_name,
100
+ )
101
+ self.execute_query(cypher, params)
102
+
103
+ def save_batch(
104
+ self,
105
+ items: list[pylpg.node.Node | pylpg.relationship.Relationship],
106
+ ) -> None:
107
+ nodes: list[pylpg.node.Node] = []
108
+ relationships: list[pylpg.relationship.Relationship] = []
109
+ for item in items:
110
+ if isinstance(item, pylpg.node.Node):
111
+ nodes.append(item)
112
+ elif isinstance(item, pylpg.relationship.Relationship):
113
+ if not item.source.is_saved():
114
+ nodes.append(item.source)
115
+ if not item.target.is_saved():
116
+ nodes.append(item.target)
117
+ relationships.append(item)
118
+ for node in nodes:
119
+ if not node.is_saved():
120
+ row = self.create_node(node)
121
+ if row:
122
+ node._database_id = row["_database_id"]
123
+ else:
124
+ self.update_node(node)
125
+ for relationship in relationships:
126
+ if not relationship.is_saved():
127
+ row = self.create_relationship(relationship)
128
+ if row:
129
+ relationship._database_id = row["_database_id"]
130
+ else:
131
+ self.update_relationship(relationship)
132
+
133
+ def traverse(
134
+ self,
135
+ node: pylpg.node.Node,
136
+ relationship_type: str,
137
+ direction: pylpg.relationship.Direction,
138
+ ) -> list[dict[str, typing.Any]]:
139
+ cypher, params = pylpg.cypher.build_traverse_query(
140
+ node=node,
141
+ relationship_type=relationship_type,
142
+ direction=direction,
143
+ database_id_func_name=self._database_id_func_name,
144
+ )
145
+ return self.execute_query(cypher, params)
146
+
147
+ def delete_all(self) -> None:
148
+ self.execute_query(pylpg.cypher.build_delete_all_query())
149
+
150
+ def close(self) -> None:
151
+ pass
@@ -0,0 +1,22 @@
1
+ """FalkorDBLite embedded backend, no server required."""
2
+
3
+ import pylpg.backend.falkordb
4
+
5
+
6
+ class FalkorDBLiteBackend(pylpg.backend.falkordb.FalkorDBBackend):
7
+ """Embedded FalkorDB backend using FalkorDBLite.
8
+
9
+ Runs FalkorDB as a local subprocess with file-based persistence.
10
+ No external server required. Requires the `falkordblite` extra.
11
+ """
12
+
13
+ def __init__(
14
+ self, path: str = "/tmp/falkordblite.db", database: str = "default"
15
+ ) -> None:
16
+ import redislite
17
+
18
+ self._db = redislite.FalkorDB(path)
19
+ self._graph = self._db.select_graph(database)
20
+
21
+ def close(self) -> None:
22
+ self._db.close()
pylpg/backend/neo4j.py ADDED
@@ -0,0 +1,255 @@
1
+ """Neo4j backend using the official Neo4j Python driver."""
2
+
3
+ import typing
4
+
5
+ import neo4j
6
+ import neo4j.graph
7
+
8
+ import pylpg.backend.base
9
+ import pylpg.cypher
10
+ import pylpg.node
11
+ import pylpg.relationship
12
+
13
+
14
+ class Neo4jBackend(pylpg.backend.base.Backend):
15
+ """Backend for Neo4j databases.
16
+
17
+ Uses UNWIND queries for batch operations, providing significant
18
+ speedups over individual queries at scale.
19
+ """
20
+
21
+ _database_id_func_name = "elementId"
22
+
23
+ def __init__(
24
+ self,
25
+ hostname: str = "localhost",
26
+ port: int = 7687,
27
+ database: str = "neo4j",
28
+ username: str = "neo4j",
29
+ password: str = "neo4j",
30
+ protocol: str = "bolt",
31
+ ) -> None:
32
+ uri = f"{protocol}://{hostname}:{port}"
33
+ self._driver = neo4j.GraphDatabase.driver(uri, auth=(username, password))
34
+ self._database = database
35
+
36
+ def execute_query(
37
+ self,
38
+ cypher: str,
39
+ parameters: dict[str, typing.Any] | None = None,
40
+ transaction: neo4j.Transaction | None = None,
41
+ ) -> list[dict[str, typing.Any]]:
42
+ if transaction is not None:
43
+ result = transaction.run(cypher, parameters=parameters or {})
44
+ keys = result.keys()
45
+ records = list(result)
46
+ else:
47
+ records, summary, keys = self._driver.execute_query(
48
+ cypher, parameters_=parameters or {}, database_=self._database
49
+ )
50
+ return [dict(zip(keys, record.values())) for record in records]
51
+
52
+ def is_node(self, value: typing.Any) -> bool:
53
+ return isinstance(value, neo4j.graph.Node)
54
+
55
+ def deserialize_node(self, record: typing.Any) -> dict[str, typing.Any]:
56
+ properties = dict(record)
57
+ properties["_labels"] = frozenset(record.labels)
58
+ properties["_database_id"] = record.element_id
59
+ return properties
60
+
61
+ def create_node(self, node: pylpg.node.Node) -> dict[str, typing.Any]:
62
+ cypher, params = pylpg.cypher.build_create_node_query(
63
+ node=node, database_id_func_name=self._database_id_func_name
64
+ )
65
+ result = self.execute_query(cypher, params)
66
+ return result[0] if result else {}
67
+
68
+ def update_node(self, node: pylpg.node.Node) -> dict[str, typing.Any]:
69
+ cypher, params = pylpg.cypher.build_update_node_query(
70
+ node=node, database_id_func_name=self._database_id_func_name
71
+ )
72
+ result = self.execute_query(cypher, params)
73
+ return result[0] if result else {}
74
+
75
+ def delete_node(self, node: pylpg.node.Node) -> None:
76
+ cypher, params = pylpg.cypher.build_delete_node_query(
77
+ node=node, database_id_func_name=self._database_id_func_name
78
+ )
79
+ self.execute_query(cypher, params)
80
+
81
+ def create_relationship(
82
+ self, relationship: pylpg.relationship.Relationship
83
+ ) -> dict[str, typing.Any]:
84
+ cypher, params = pylpg.cypher.build_create_relationship_query(
85
+ relationship=relationship,
86
+ database_id_func_name=self._database_id_func_name,
87
+ )
88
+ result = self.execute_query(cypher, params)
89
+ return result[0] if result else {}
90
+
91
+ def update_relationship(
92
+ self, relationship: pylpg.relationship.Relationship
93
+ ) -> dict[str, typing.Any]:
94
+ cypher, params = pylpg.cypher.build_update_relationship_query(
95
+ relationship=relationship,
96
+ database_id_func_name=self._database_id_func_name,
97
+ )
98
+ result = self.execute_query(cypher, params)
99
+ return result[0] if result else {}
100
+
101
+ def delete_relationship(
102
+ self, relationship: pylpg.relationship.Relationship
103
+ ) -> None:
104
+ cypher, params = pylpg.cypher.build_delete_relationship_query(
105
+ relationship=relationship,
106
+ database_id_func_name=self._database_id_func_name,
107
+ )
108
+ self.execute_query(cypher, params)
109
+
110
+ def save_batch(
111
+ self,
112
+ items: list[pylpg.node.Node | pylpg.relationship.Relationship],
113
+ ) -> None:
114
+ new_node_groups: dict[frozenset[str], list[pylpg.node.Node]] = {}
115
+ existing_node_groups: dict[frozenset[str], list[pylpg.node.Node]] = {}
116
+ new_relationship_groups: dict[str, list[pylpg.relationship.Relationship]] = {}
117
+ existing_relationship_groups: dict[
118
+ str, list[pylpg.relationship.Relationship]
119
+ ] = {}
120
+ seen_nodes: set[int] = set()
121
+
122
+ for item in items:
123
+ if isinstance(item, pylpg.node.Node):
124
+ self._triage_node(
125
+ node=item,
126
+ new_node_groups=new_node_groups,
127
+ existing_node_groups=existing_node_groups,
128
+ seen_nodes=seen_nodes,
129
+ )
130
+ elif isinstance(item, pylpg.relationship.Relationship):
131
+ self._triage_node(
132
+ node=item.source,
133
+ new_node_groups=new_node_groups,
134
+ existing_node_groups=existing_node_groups,
135
+ seen_nodes=seen_nodes,
136
+ )
137
+ self._triage_node(
138
+ node=item.target,
139
+ new_node_groups=new_node_groups,
140
+ existing_node_groups=existing_node_groups,
141
+ seen_nodes=seen_nodes,
142
+ )
143
+ if not item.is_saved():
144
+ new_relationship_groups.setdefault(item.__type__, []).append(item)
145
+ else:
146
+ existing_relationship_groups.setdefault(item.__type__, []).append(
147
+ item
148
+ )
149
+
150
+ with self._driver.session(database=self._database) as session:
151
+ with session.begin_transaction() as transaction:
152
+ for new_nodes in new_node_groups.values():
153
+ self._batch_create_nodes(nodes=new_nodes, transaction=transaction)
154
+ for existing_nodes in existing_node_groups.values():
155
+ self._batch_update_nodes(
156
+ nodes=existing_nodes, transaction=transaction
157
+ )
158
+ for new_relationships in new_relationship_groups.values():
159
+ self._batch_create_relationships(
160
+ relationships=new_relationships, transaction=transaction
161
+ )
162
+ for existing_relationships in existing_relationship_groups.values():
163
+ self._batch_update_relationships(
164
+ relationships=existing_relationships,
165
+ transaction=transaction,
166
+ )
167
+ transaction.commit()
168
+
169
+ def _triage_node(
170
+ self,
171
+ node: pylpg.node.Node,
172
+ new_node_groups: dict[frozenset[str], list[pylpg.node.Node]],
173
+ existing_node_groups: dict[frozenset[str], list[pylpg.node.Node]],
174
+ seen_nodes: set[int],
175
+ ) -> None:
176
+ node_id = id(node)
177
+ if node_id in seen_nodes:
178
+ return
179
+ seen_nodes.add(node_id)
180
+ if not node.is_saved():
181
+ new_node_groups.setdefault(node.__labels__, []).append(node)
182
+ else:
183
+ existing_node_groups.setdefault(node.__labels__, []).append(node)
184
+
185
+ def _batch_create_nodes(
186
+ self,
187
+ nodes: list[pylpg.node.Node],
188
+ transaction: neo4j.Transaction,
189
+ ) -> None:
190
+ cypher, params = pylpg.cypher.build_batch_create_nodes_query(
191
+ nodes=nodes, database_id_func_name=self._database_id_func_name
192
+ )
193
+ results = self.execute_query(cypher, params, transaction=transaction)
194
+ temp_id_to_node = {node._temp_id: node for node in nodes}
195
+ for row in results:
196
+ node = temp_id_to_node[row["temp_id"]]
197
+ node._database_id = row["_database_id"]
198
+
199
+ def _batch_update_nodes(
200
+ self,
201
+ nodes: list[pylpg.node.Node],
202
+ transaction: neo4j.Transaction,
203
+ ) -> None:
204
+ cypher, params = pylpg.cypher.build_batch_update_nodes_query(
205
+ nodes=nodes, database_id_func_name=self._database_id_func_name
206
+ )
207
+ self.execute_query(cypher, params, transaction=transaction)
208
+
209
+ def _batch_create_relationships(
210
+ self,
211
+ relationships: list[pylpg.relationship.Relationship],
212
+ transaction: neo4j.Transaction,
213
+ ) -> None:
214
+ cypher, params = pylpg.cypher.build_batch_create_relationships_query(
215
+ relationships=relationships,
216
+ database_id_func_name=self._database_id_func_name,
217
+ )
218
+ results = self.execute_query(cypher, params, transaction=transaction)
219
+ temp_id_to_relationship = {
220
+ relationship._temp_id: relationship for relationship in relationships
221
+ }
222
+ for row in results:
223
+ relationship = temp_id_to_relationship[row["temp_id"]]
224
+ relationship._database_id = row["_database_id"]
225
+
226
+ def _batch_update_relationships(
227
+ self,
228
+ relationships: list[pylpg.relationship.Relationship],
229
+ transaction: neo4j.Transaction,
230
+ ) -> None:
231
+ cypher, params = pylpg.cypher.build_batch_update_relationships_query(
232
+ relationships=relationships,
233
+ database_id_func_name=self._database_id_func_name,
234
+ )
235
+ self.execute_query(cypher, params, transaction=transaction)
236
+
237
+ def traverse(
238
+ self,
239
+ node: pylpg.node.Node,
240
+ relationship_type: str,
241
+ direction: pylpg.relationship.Direction,
242
+ ) -> list[dict[str, typing.Any]]:
243
+ cypher, params = pylpg.cypher.build_traverse_query(
244
+ node=node,
245
+ relationship_type=relationship_type,
246
+ direction=direction,
247
+ database_id_func_name=self._database_id_func_name,
248
+ )
249
+ return self.execute_query(cypher, params)
250
+
251
+ def delete_all(self) -> None:
252
+ self.execute_query(pylpg.cypher.build_delete_all_query())
253
+
254
+ def close(self) -> None:
255
+ self._driver.close()