graphitedb 0.1.3__py3-none-any.whl → 0.2__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.
- graphite/__init__.py +20 -728
- graphite/engine.py +564 -0
- graphite/exceptions.py +89 -0
- graphite/instances.py +50 -0
- graphite/migration.py +104 -0
- graphite/parser.py +228 -0
- graphite/query.py +199 -0
- graphite/serialization.py +174 -0
- graphite/types.py +65 -0
- graphite/utils.py +34 -0
- {graphitedb-0.1.3.dist-info → graphitedb-0.2.dist-info}/METADATA +1 -1
- graphitedb-0.2.dist-info/RECORD +15 -0
- graphitedb-0.1.3.dist-info/RECORD +0 -6
- {graphitedb-0.1.3.dist-info → graphitedb-0.2.dist-info}/WHEEL +0 -0
- {graphitedb-0.1.3.dist-info → graphitedb-0.2.dist-info}/licenses/LICENSE +0 -0
- {graphitedb-0.1.3.dist-info → graphitedb-0.2.dist-info}/top_level.txt +0 -0
graphite/__init__.py
CHANGED
|
@@ -4,731 +4,23 @@ Graphite: A clean, embedded graph database engine for Python.
|
|
|
4
4
|
This is graphite module (installation: ``pip install graphitedb``).
|
|
5
5
|
You can use it with ``import graphite``.
|
|
6
6
|
"""
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
from
|
|
12
|
-
from
|
|
13
|
-
from
|
|
14
|
-
from
|
|
15
|
-
from
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
BOOL = "bool"
|
|
28
|
-
|
|
29
|
-
@dataclass
|
|
30
|
-
class Field:
|
|
31
|
-
"""
|
|
32
|
-
A data field (property) for nodes and relations.
|
|
33
|
-
"""
|
|
34
|
-
name: str
|
|
35
|
-
dtype: DataType
|
|
36
|
-
default: Any = None
|
|
37
|
-
|
|
38
|
-
@dataclass
|
|
39
|
-
class NodeType:
|
|
40
|
-
"""
|
|
41
|
-
A defined node type (with ``node ...`` block in dsl or ``GraphiteEngine.define_node()``).
|
|
42
|
-
Each node type has a name (in snake_case usually), and optional list of fields (properties).
|
|
43
|
-
Supports optional parent node type.
|
|
44
|
-
"""
|
|
45
|
-
name: str
|
|
46
|
-
fields: List[Field] = field(default_factory=list)
|
|
47
|
-
parent: Optional[NodeType] = None
|
|
48
|
-
|
|
49
|
-
def get_all_fields(self) -> List[Field]:
|
|
50
|
-
"""Get all fields including inherited ones"""
|
|
51
|
-
fields = self.fields.copy()
|
|
52
|
-
if self.parent:
|
|
53
|
-
fields = self.parent.get_all_fields() + fields
|
|
54
|
-
return fields
|
|
55
|
-
|
|
56
|
-
def __hash__(self):
|
|
57
|
-
return hash(self.name)
|
|
58
|
-
|
|
59
|
-
@dataclass
|
|
60
|
-
class RelationType:
|
|
61
|
-
"""
|
|
62
|
-
A defined relation type (with ``relation ...`` block in dsl or
|
|
63
|
-
``GraphiteEngine.define_relation()``). Each relation type has a name (in UPPER_SNAKE_CASE
|
|
64
|
-
usually), and optional list of fields (properties). A relation type can be from one node
|
|
65
|
-
type to another.
|
|
66
|
-
"""
|
|
67
|
-
name: str
|
|
68
|
-
from_type: str
|
|
69
|
-
to_type: str
|
|
70
|
-
fields: List[Field] = field(default_factory=list)
|
|
71
|
-
reverse_name: Optional[str] = None
|
|
72
|
-
is_bidirectional: bool = False
|
|
73
|
-
|
|
74
|
-
def __hash__(self):
|
|
75
|
-
return hash(self.name)
|
|
76
|
-
|
|
77
|
-
# =============== INSTANCES ===============
|
|
78
|
-
|
|
79
|
-
@dataclass
|
|
80
|
-
class Node:
|
|
81
|
-
"""
|
|
82
|
-
A node in database. Has a base type, id, and properties from base type (and it's parent
|
|
83
|
-
type recursively).
|
|
84
|
-
"""
|
|
85
|
-
type_name: str
|
|
86
|
-
id: str
|
|
87
|
-
values: Dict[str, Any]
|
|
88
|
-
_type_ref: Optional[NodeType] = None
|
|
89
|
-
|
|
90
|
-
def get(self, field_name: str) -> Any:
|
|
91
|
-
"""Get a field from this node."""
|
|
92
|
-
return self.values.get(field_name)
|
|
93
|
-
|
|
94
|
-
def __getitem__(self, key):
|
|
95
|
-
return self.get(key)
|
|
96
|
-
|
|
97
|
-
def __repr__(self):
|
|
98
|
-
return f"Node({self.type_name}:{self.id})"
|
|
99
|
-
|
|
100
|
-
@dataclass
|
|
101
|
-
class Relation:
|
|
102
|
-
"""
|
|
103
|
-
A relation between two nodes in database. Has a base type, source and target node IDs,
|
|
104
|
-
and properties from base type.
|
|
105
|
-
"""
|
|
106
|
-
type_name: str
|
|
107
|
-
from_node: str # node id
|
|
108
|
-
to_node: str # node id
|
|
109
|
-
values: Dict[str, Any]
|
|
110
|
-
_type_ref: Optional[RelationType] = None
|
|
111
|
-
|
|
112
|
-
def get(self, field_name: str) -> Any:
|
|
113
|
-
"""Get a field from this relation."""
|
|
114
|
-
return self.values.get(field_name)
|
|
115
|
-
|
|
116
|
-
def __repr__(self):
|
|
117
|
-
return f"Relation({self.type_name}:{self.from_node}->{self.to_node})"
|
|
118
|
-
|
|
119
|
-
# =============== PARSER ===============
|
|
120
|
-
|
|
121
|
-
class GraphiteParser:
|
|
122
|
-
"""Parser for Graphite DSL"""
|
|
123
|
-
|
|
124
|
-
@staticmethod
|
|
125
|
-
def parse_node_definition(line: str) -> Tuple[str, List[Field], str]:
|
|
126
|
-
"""Parse node type definition: 'node Person\nname: string\nage: int'"""
|
|
127
|
-
lines = line.strip().split('\n')
|
|
128
|
-
first_line = lines[0].strip()
|
|
129
|
-
|
|
130
|
-
# Parse inheritance
|
|
131
|
-
if ' from ' in first_line:
|
|
132
|
-
parts = first_line.split(' from ')
|
|
133
|
-
node_name = parts[0].replace('node', '').strip()
|
|
134
|
-
parent = parts[1].strip()
|
|
135
|
-
fields_start = 1
|
|
136
|
-
else:
|
|
137
|
-
node_name = first_line.replace('node', '').strip()
|
|
138
|
-
parent = None
|
|
139
|
-
fields_start = 1
|
|
140
|
-
|
|
141
|
-
fields = []
|
|
142
|
-
for field_line in lines[fields_start:]:
|
|
143
|
-
field_line = field_line.strip()
|
|
144
|
-
if not field_line:
|
|
145
|
-
continue
|
|
146
|
-
name_type = field_line.split(':')
|
|
147
|
-
if len(name_type) == 2:
|
|
148
|
-
name = name_type[0].strip()
|
|
149
|
-
dtype_str = name_type[1].strip()
|
|
150
|
-
dtype = DataType(dtype_str)
|
|
151
|
-
fields.append(Field(name, dtype))
|
|
152
|
-
|
|
153
|
-
return node_name, fields, parent
|
|
154
|
-
|
|
155
|
-
# pylint: disable=too-many-locals
|
|
156
|
-
@staticmethod
|
|
157
|
-
def parse_relation_definition(line: str) -> Tuple[str, str, str, List[Field], Optional[str], bool]:
|
|
158
|
-
"""Parse relation definition"""
|
|
159
|
-
lines = line.strip().split('\n')
|
|
160
|
-
first_line = lines[0].strip()
|
|
161
|
-
|
|
162
|
-
# Check for 'both' keyword
|
|
163
|
-
is_bidirectional = ' both' in first_line
|
|
164
|
-
if is_bidirectional:
|
|
165
|
-
first_line = first_line.replace(' both', '')
|
|
166
|
-
|
|
167
|
-
# Parse reverse
|
|
168
|
-
reverse_name = None
|
|
169
|
-
if ' reverse ' in first_line:
|
|
170
|
-
parts = first_line.split(' reverse ')
|
|
171
|
-
relation_name = parts[0].replace('relation', '').strip()
|
|
172
|
-
reverse_name = parts[1].strip()
|
|
173
|
-
first_line = parts[0]
|
|
174
|
-
else:
|
|
175
|
-
relation_name = first_line.replace('relation', '').strip()
|
|
176
|
-
|
|
177
|
-
# Parse participants
|
|
178
|
-
participants_line = lines[1].strip()
|
|
179
|
-
if '->' in participants_line:
|
|
180
|
-
from_to = participants_line.split('->')
|
|
181
|
-
from_type = from_to[0].strip()
|
|
182
|
-
to_type = from_to[1].strip()
|
|
183
|
-
elif '-' in participants_line:
|
|
184
|
-
parts = participants_line.split('-')
|
|
185
|
-
from_type = parts[0].strip()
|
|
186
|
-
to_type = parts[2].strip() if len(parts) > 2 else parts[1].strip()
|
|
187
|
-
else:
|
|
188
|
-
raise ValueError(f"Invalid relation format: {participants_line}")
|
|
189
|
-
|
|
190
|
-
# Parse fields
|
|
191
|
-
fields = []
|
|
192
|
-
for field_line in lines[2:]:
|
|
193
|
-
field_line = field_line.strip()
|
|
194
|
-
if not field_line:
|
|
195
|
-
continue
|
|
196
|
-
name_type = field_line.split(':')
|
|
197
|
-
if len(name_type) == 2:
|
|
198
|
-
name = name_type[0].strip()
|
|
199
|
-
dtype_str = name_type[1].strip()
|
|
200
|
-
dtype = DataType(dtype_str)
|
|
201
|
-
fields.append(Field(name, dtype))
|
|
202
|
-
|
|
203
|
-
return relation_name, from_type, to_type, fields, reverse_name, is_bidirectional
|
|
204
|
-
|
|
205
|
-
@staticmethod
|
|
206
|
-
def parse_node_instance(line: str) -> Tuple[str, str, List[Any]]:
|
|
207
|
-
"""Parse node instance: 'User, user_1, "Joe Doe", 32, "joe4030"'"""
|
|
208
|
-
# Handle quoted strings
|
|
209
|
-
parts = []
|
|
210
|
-
current = ''
|
|
211
|
-
in_quotes = False
|
|
212
|
-
for char in line:
|
|
213
|
-
if char == '"':
|
|
214
|
-
in_quotes = not in_quotes
|
|
215
|
-
current += char
|
|
216
|
-
elif char == ',' and not in_quotes:
|
|
217
|
-
parts.append(current.strip())
|
|
218
|
-
current = ''
|
|
219
|
-
else:
|
|
220
|
-
current += char
|
|
221
|
-
if current:
|
|
222
|
-
parts.append(current.strip())
|
|
223
|
-
|
|
224
|
-
node_type = parts[0].strip()
|
|
225
|
-
node_id = parts[1].strip()
|
|
226
|
-
values = []
|
|
227
|
-
|
|
228
|
-
for val in parts[2:]:
|
|
229
|
-
val = val.strip()
|
|
230
|
-
if val.startswith('"') and val.endswith('"'):
|
|
231
|
-
values.append(val[1:-1])
|
|
232
|
-
elif val.replace('-', '').isdigit() and '-' in val: # Date-like
|
|
233
|
-
values.append(val)
|
|
234
|
-
elif val.isdigit() or (val.startswith('-') and val[1:].isdigit()):
|
|
235
|
-
values.append(int(val))
|
|
236
|
-
elif val.replace('.', '').isdigit() and val.count('.') == 1:
|
|
237
|
-
values.append(float(val))
|
|
238
|
-
elif val.lower() in ('true', 'false'):
|
|
239
|
-
values.append(val.lower() == 'true')
|
|
240
|
-
else:
|
|
241
|
-
values.append(val)
|
|
242
|
-
|
|
243
|
-
return node_type, node_id, values
|
|
244
|
-
|
|
245
|
-
@staticmethod
|
|
246
|
-
def parse_relation_instance(line: str) -> tuple[str | Any, str | Any, Any, list[Any], str]:
|
|
247
|
-
"""Parse relation instance: 'user_1 -[OWNER, 2000-10-04]-> notebook'"""
|
|
248
|
-
# Extract relation type and attributes
|
|
249
|
-
pattern = r'(\w+)\s*(-\[([^\]]+)\]\s*[->-]\s*|\s*[->-]\s*\[([^\]]+)\]\s*->\s*)(\w+)'
|
|
250
|
-
match = re.search(pattern, line)
|
|
251
|
-
if not match:
|
|
252
|
-
raise ValueError(f"Invalid relation format: {line}")
|
|
253
|
-
|
|
254
|
-
from_node = match.group(1)
|
|
255
|
-
to_node = match.group(5)
|
|
256
|
-
|
|
257
|
-
# Get relation type and attributes
|
|
258
|
-
rel_part = match.group(3) or match.group(4)
|
|
259
|
-
rel_parts = [p.strip() for p in rel_part.split(',')]
|
|
260
|
-
rel_type = rel_parts[0]
|
|
261
|
-
attributes = rel_parts[1:] if len(rel_parts) > 1 else []
|
|
262
|
-
|
|
263
|
-
# Parse direction
|
|
264
|
-
if '->' in line:
|
|
265
|
-
direction = 'forward'
|
|
266
|
-
elif '-[' in line and ']-' in line:
|
|
267
|
-
direction = 'bidirectional'
|
|
268
|
-
else:
|
|
269
|
-
direction = 'forward'
|
|
270
|
-
|
|
271
|
-
return from_node, to_node, rel_type, attributes, direction
|
|
272
|
-
|
|
273
|
-
# =============== QUERY ENGINE ===============
|
|
274
|
-
|
|
275
|
-
class QueryResult:
|
|
276
|
-
"""Represents a query result that can be chained"""
|
|
277
|
-
|
|
278
|
-
def __init__(self, graph_engine: GraphiteEngine, nodes: List[Node], edges: List[Relation] = None):
|
|
279
|
-
self.engine = graph_engine
|
|
280
|
-
self.nodes = nodes
|
|
281
|
-
self.edges = edges or []
|
|
282
|
-
self.current_relation: Optional[RelationType] = None
|
|
283
|
-
self.direction: str = 'outgoing'
|
|
284
|
-
|
|
285
|
-
def where(self, condition: Union[str, Callable]) -> QueryResult:
|
|
286
|
-
"""Filter nodes based on condition"""
|
|
287
|
-
filtered_nodes = []
|
|
288
|
-
|
|
289
|
-
if callable(condition):
|
|
290
|
-
# Lambda function
|
|
291
|
-
for processing_node in self.nodes:
|
|
292
|
-
try:
|
|
293
|
-
if condition(processing_node):
|
|
294
|
-
filtered_nodes.append(processing_node)
|
|
295
|
-
except Exception as e: # pylint: disable=broad-exception-caught
|
|
296
|
-
print(f"Graphite Warn: 'where' condition failed for node {processing_node}: {e}")
|
|
297
|
-
else:
|
|
298
|
-
# String condition like "age > 18"
|
|
299
|
-
for processing_node in self.nodes:
|
|
300
|
-
if self._evaluate_condition(processing_node, condition):
|
|
301
|
-
filtered_nodes.append(processing_node)
|
|
302
|
-
|
|
303
|
-
return QueryResult(self.engine, filtered_nodes, self.edges)
|
|
304
|
-
|
|
305
|
-
# pylint: disable=too-many-branches
|
|
306
|
-
def _evaluate_condition(self, target_node: Node, condition: str) -> bool:
|
|
307
|
-
"""Evaluate a condition string on a node"""
|
|
308
|
-
# Simple condition parser
|
|
309
|
-
ops = ['>=', '<=', '!=', '==', '>', '<', '=']
|
|
310
|
-
|
|
311
|
-
for op in ops:
|
|
312
|
-
if op in condition:
|
|
313
|
-
left, right = condition.split(op)
|
|
314
|
-
left = left.strip()
|
|
315
|
-
right = right.strip()
|
|
316
|
-
|
|
317
|
-
# Get value from node
|
|
318
|
-
node_value = target_node.get(left)
|
|
319
|
-
if node_value is None:
|
|
320
|
-
return False
|
|
321
|
-
|
|
322
|
-
# Parse right side
|
|
323
|
-
if right.startswith('"') and right.endswith('"'):
|
|
324
|
-
right_value = right[1:-1]
|
|
325
|
-
elif right.isdigit():
|
|
326
|
-
right_value = int(right)
|
|
327
|
-
elif right.replace('.', '').isdigit() and right.count('.') == 1:
|
|
328
|
-
right_value = float(right)
|
|
329
|
-
else:
|
|
330
|
-
right_value = right
|
|
331
|
-
|
|
332
|
-
# Apply operation
|
|
333
|
-
result = None
|
|
334
|
-
if op in ('=', '=='):
|
|
335
|
-
result = node_value == right_value
|
|
336
|
-
if op == '!=':
|
|
337
|
-
result = node_value != right_value
|
|
338
|
-
if op == '>':
|
|
339
|
-
result = node_value > right_value
|
|
340
|
-
if op == '<':
|
|
341
|
-
result = node_value < right_value
|
|
342
|
-
if op == '>=':
|
|
343
|
-
result = node_value >= right_value
|
|
344
|
-
if op == '<=':
|
|
345
|
-
result = node_value <= right_value
|
|
346
|
-
if result is None:
|
|
347
|
-
raise ValueError(f"Invalid condition string: {condition}")
|
|
348
|
-
return result
|
|
349
|
-
|
|
350
|
-
return False
|
|
351
|
-
|
|
352
|
-
def traverse(self, relation_type: str, direction: str = 'outgoing') -> QueryResult:
|
|
353
|
-
"""Traverse relations from current nodes"""
|
|
354
|
-
result_nodes = []
|
|
355
|
-
result_edges = []
|
|
356
|
-
|
|
357
|
-
for processing_node in self.nodes:
|
|
358
|
-
if direction == 'outgoing':
|
|
359
|
-
edges = self.engine.get_relations_from(processing_node.id, relation_type)
|
|
360
|
-
elif direction == 'incoming':
|
|
361
|
-
edges = self.engine.get_relations_to(processing_node.id, relation_type)
|
|
362
|
-
else: # both
|
|
363
|
-
edges = (self.engine.get_relations_from(processing_node.id, relation_type) +
|
|
364
|
-
self.engine.get_relations_to(processing_node.id, relation_type))
|
|
365
|
-
|
|
366
|
-
for edge in edges:
|
|
367
|
-
result_edges.append(edge)
|
|
368
|
-
target_id = edge.to_node if direction == 'outgoing' else edge.from_node
|
|
369
|
-
target_node = self.engine.get_node(target_id)
|
|
370
|
-
if target_node:
|
|
371
|
-
result_nodes.append(target_node)
|
|
372
|
-
|
|
373
|
-
# Remove duplicates
|
|
374
|
-
result_nodes = list(dict((n.id, n) for n in result_nodes).values())
|
|
375
|
-
return QueryResult(self.engine, result_nodes, result_edges)
|
|
376
|
-
|
|
377
|
-
def outgoing(self, relation_type: str) -> QueryResult:
|
|
378
|
-
"""Traverse outgoing relations"""
|
|
379
|
-
return self.traverse(relation_type, 'outgoing')
|
|
380
|
-
|
|
381
|
-
def incoming(self, relation_type: str) -> QueryResult:
|
|
382
|
-
"""Traverse incoming relations"""
|
|
383
|
-
return self.traverse(relation_type, 'incoming')
|
|
384
|
-
|
|
385
|
-
def both(self, relation_type: str) -> QueryResult:
|
|
386
|
-
"""Traverse both directions"""
|
|
387
|
-
return self.traverse(relation_type, 'both')
|
|
388
|
-
|
|
389
|
-
def limit(self, n: int) -> QueryResult:
|
|
390
|
-
"""Limit number of results"""
|
|
391
|
-
return QueryResult(self.engine, self.nodes[:n], self.edges[:n])
|
|
392
|
-
|
|
393
|
-
def distinct(self) -> QueryResult:
|
|
394
|
-
"""Get distinct nodes"""
|
|
395
|
-
seen = set()
|
|
396
|
-
distinct_nodes = []
|
|
397
|
-
for processing_node in self.nodes:
|
|
398
|
-
if processing_node.id not in seen:
|
|
399
|
-
seen.add(processing_node.id)
|
|
400
|
-
distinct_nodes.append(processing_node)
|
|
401
|
-
return QueryResult(self.engine, distinct_nodes, self.edges)
|
|
402
|
-
|
|
403
|
-
def order_by(self, by_field: str, descending: bool = False) -> QueryResult:
|
|
404
|
-
"""Order nodes by field"""
|
|
405
|
-
|
|
406
|
-
def get_key(from_node):
|
|
407
|
-
val = from_node.get(by_field)
|
|
408
|
-
return (val is None, val)
|
|
409
|
-
|
|
410
|
-
sorted_nodes = sorted(self.nodes, key=get_key, reverse=descending)
|
|
411
|
-
return QueryResult(self.engine, sorted_nodes, self.edges)
|
|
412
|
-
|
|
413
|
-
def count(self) -> int:
|
|
414
|
-
"""Count nodes"""
|
|
415
|
-
return len(self.nodes)
|
|
416
|
-
|
|
417
|
-
def get(self) -> List[Node]:
|
|
418
|
-
"""Get all nodes"""
|
|
419
|
-
return self.nodes
|
|
420
|
-
|
|
421
|
-
def first(self) -> Optional[Node]:
|
|
422
|
-
"""Get first node"""
|
|
423
|
-
return self.nodes[0] if self.nodes else None
|
|
424
|
-
|
|
425
|
-
def ids(self) -> List[str]:
|
|
426
|
-
"""Get node IDs"""
|
|
427
|
-
return [n.id for n in self.nodes]
|
|
428
|
-
|
|
429
|
-
class QueryBuilder: # pylint: disable=too-few-public-methods
|
|
430
|
-
"""Builder for creating queries"""
|
|
431
|
-
|
|
432
|
-
def __init__(self, graphite_engine: GraphiteEngine):
|
|
433
|
-
self.engine = graphite_engine
|
|
434
|
-
|
|
435
|
-
def __getattr__(self, name: str) -> QueryResult:
|
|
436
|
-
"""Allow starting query from node type: engine.User"""
|
|
437
|
-
if name in self.engine.node_types:
|
|
438
|
-
nodes = self.engine.get_nodes_of_type(name)
|
|
439
|
-
return QueryResult(self.engine, nodes)
|
|
440
|
-
raise AttributeError(f"No node type '{name}' found")
|
|
441
|
-
|
|
442
|
-
# =============== MAIN ENGINE ===============
|
|
443
|
-
|
|
444
|
-
class GraphiteEngine: # pylint: disable=too-many-instance-attributes
|
|
445
|
-
"""Main graph database engine"""
|
|
446
|
-
|
|
447
|
-
def __init__(self):
|
|
448
|
-
self.node_types: Dict[str, NodeType] = {}
|
|
449
|
-
self.relation_types: Dict[str, RelationType] = {}
|
|
450
|
-
self.nodes: Dict[str, Node] = {}
|
|
451
|
-
self.relations: List[Relation] = []
|
|
452
|
-
self.node_by_type: Dict[str, List[Node]] = defaultdict(list)
|
|
453
|
-
self.relations_by_type: Dict[str, List[Relation]] = defaultdict(list)
|
|
454
|
-
self.relations_by_from: Dict[str, List[Relation]] = defaultdict(list)
|
|
455
|
-
self.relations_by_to: Dict[str, List[Relation]] = defaultdict(list)
|
|
456
|
-
self.parser = GraphiteParser()
|
|
457
|
-
self.query = QueryBuilder(self)
|
|
458
|
-
|
|
459
|
-
# =============== SCHEMA DEFINITION ===============
|
|
460
|
-
|
|
461
|
-
def define_node(self, definition: str):
|
|
462
|
-
"""Define a node type from DSL"""
|
|
463
|
-
node_name, fields, parent_name = self.parser.parse_node_definition(definition)
|
|
464
|
-
|
|
465
|
-
parent = None
|
|
466
|
-
if parent_name:
|
|
467
|
-
if parent_name not in self.node_types:
|
|
468
|
-
raise ValueError(f"Parent node type '{parent_name}' not found")
|
|
469
|
-
parent = self.node_types[parent_name]
|
|
470
|
-
|
|
471
|
-
node_type = NodeType(node_name, fields, parent)
|
|
472
|
-
self.node_types[node_name] = node_type
|
|
473
|
-
|
|
474
|
-
def define_relation(self, definition: str):
|
|
475
|
-
"""Define a relation type from DSL"""
|
|
476
|
-
(rel_name, from_type, to_type, fields,
|
|
477
|
-
reverse_name, is_bidirectional) = self.parser.parse_relation_definition(definition)
|
|
478
|
-
|
|
479
|
-
# Validate node types exist
|
|
480
|
-
if from_type not in self.node_types:
|
|
481
|
-
raise ValueError(f"Node type '{from_type}' not found")
|
|
482
|
-
if to_type not in self.node_types:
|
|
483
|
-
raise ValueError(f"Node type '{to_type}' not found")
|
|
484
|
-
|
|
485
|
-
rel_type = RelationType(
|
|
486
|
-
rel_name, from_type, to_type,
|
|
487
|
-
fields, reverse_name, is_bidirectional
|
|
488
|
-
)
|
|
489
|
-
self.relation_types[rel_name] = rel_type
|
|
490
|
-
|
|
491
|
-
# Register reverse relation if specified
|
|
492
|
-
if reverse_name:
|
|
493
|
-
reverse_rel = RelationType(
|
|
494
|
-
reverse_name, to_type, from_type,
|
|
495
|
-
fields, rel_name, is_bidirectional
|
|
496
|
-
)
|
|
497
|
-
self.relation_types[reverse_name] = reverse_rel
|
|
498
|
-
|
|
499
|
-
# =============== DATA MANIPULATION ===============
|
|
500
|
-
|
|
501
|
-
def create_node(self, node_type: str, node_id: str, *values) -> Node:
|
|
502
|
-
"""Create a node instance"""
|
|
503
|
-
if node_type not in self.node_types:
|
|
504
|
-
raise ValueError(f"Node type '{node_type}' not defined")
|
|
505
|
-
|
|
506
|
-
node_type_obj = self.node_types[node_type]
|
|
507
|
-
all_fields = node_type_obj.get_all_fields()
|
|
508
|
-
|
|
509
|
-
if len(values) != len(all_fields):
|
|
510
|
-
raise ValueError(f"Expected {len(all_fields)} values, got {len(values)}")
|
|
511
|
-
|
|
512
|
-
# Create values dictionary
|
|
513
|
-
node_values = {}
|
|
514
|
-
for current_field, value in zip(all_fields, values):
|
|
515
|
-
# Convert string dates to date objects
|
|
516
|
-
if current_field.dtype == DataType.DATE and isinstance(value, str):
|
|
517
|
-
try:
|
|
518
|
-
value = datetime.strptime(value, "%Y-%m-%d").date()
|
|
519
|
-
except Exception as e:
|
|
520
|
-
raise ValueError(f"'{e}' while parsing date string: {value}") from e
|
|
521
|
-
node_values[current_field.name] = value
|
|
522
|
-
|
|
523
|
-
new_node = Node(node_type, node_id, node_values, node_type_obj)
|
|
524
|
-
self.nodes[node_id] = new_node
|
|
525
|
-
self.node_by_type[node_type].append(new_node)
|
|
526
|
-
return new_node
|
|
527
|
-
|
|
528
|
-
def create_relation(self, from_id: str, to_id: str, rel_type: str, *values) -> Relation:
|
|
529
|
-
"""Create a relation instance"""
|
|
530
|
-
if rel_type not in self.relation_types:
|
|
531
|
-
raise ValueError(f"Relation type '{rel_type}' not defined")
|
|
532
|
-
|
|
533
|
-
rel_type_obj = self.relation_types[rel_type]
|
|
534
|
-
|
|
535
|
-
# Check if nodes exist
|
|
536
|
-
if from_id not in self.nodes:
|
|
537
|
-
raise ValueError(f"Node '{from_id}' not found")
|
|
538
|
-
if to_id not in self.nodes:
|
|
539
|
-
raise ValueError(f"Node '{to_id}' not found")
|
|
540
|
-
|
|
541
|
-
# Create values dictionary
|
|
542
|
-
rel_values = {}
|
|
543
|
-
for i, rel_field in enumerate(rel_type_obj.fields):
|
|
544
|
-
if i < len(values):
|
|
545
|
-
value = values[i]
|
|
546
|
-
if rel_field.dtype == DataType.DATE and isinstance(value, str):
|
|
547
|
-
try:
|
|
548
|
-
value = datetime.strptime(value, "%Y-%m-%d").date()
|
|
549
|
-
except Exception as e:
|
|
550
|
-
raise ValueError(f"'{e}' while parsing date string: {value}") from e
|
|
551
|
-
rel_values[rel_field.name] = value
|
|
552
|
-
|
|
553
|
-
new_relation = Relation(rel_type, from_id, to_id, rel_values, rel_type_obj)
|
|
554
|
-
self.relations.append(new_relation)
|
|
555
|
-
self.relations_by_type[rel_type].append(new_relation)
|
|
556
|
-
self.relations_by_from[from_id].append(new_relation)
|
|
557
|
-
self.relations_by_to[to_id].append(new_relation)
|
|
558
|
-
|
|
559
|
-
# If relation is bidirectional, create reverse automatically
|
|
560
|
-
if rel_type_obj.is_bidirectional:
|
|
561
|
-
reverse_rel = Relation(rel_type, to_id, from_id, rel_values, rel_type_obj)
|
|
562
|
-
self.relations.append(reverse_rel)
|
|
563
|
-
self.relations_by_type[rel_type].append(reverse_rel)
|
|
564
|
-
self.relations_by_from[to_id].append(reverse_rel)
|
|
565
|
-
self.relations_by_to[from_id].append(reverse_rel)
|
|
566
|
-
|
|
567
|
-
return new_relation
|
|
568
|
-
|
|
569
|
-
# =============== QUERY METHODS ===============
|
|
570
|
-
|
|
571
|
-
def get_node(self, node_id: str) -> Optional[Node]:
|
|
572
|
-
"""Get node by ID"""
|
|
573
|
-
return self.nodes.get(node_id)
|
|
574
|
-
|
|
575
|
-
def get_nodes_of_type(self, node_type: str) -> List[Node]:
|
|
576
|
-
"""Get all nodes of a specific type"""
|
|
577
|
-
return self.node_by_type.get(node_type, [])
|
|
578
|
-
|
|
579
|
-
def get_relations_from(self, node_id: str, rel_type: str = None) -> List[Relation]:
|
|
580
|
-
"""Get relations from a node"""
|
|
581
|
-
all_rels = self.relations_by_from.get(node_id, [])
|
|
582
|
-
if rel_type:
|
|
583
|
-
return [r for r in all_rels if r.type_name == rel_type]
|
|
584
|
-
return all_rels
|
|
585
|
-
|
|
586
|
-
def get_relations_to(self, node_id: str, rel_type: str = None) -> List[Relation]:
|
|
587
|
-
"""Get relations to a node"""
|
|
588
|
-
all_rels = self.relations_by_to.get(node_id, [])
|
|
589
|
-
if rel_type:
|
|
590
|
-
return [r for r in all_rels if r.type_name == rel_type]
|
|
591
|
-
return all_rels
|
|
592
|
-
|
|
593
|
-
# =============== BULK LOADING ===============
|
|
594
|
-
|
|
595
|
-
def load_dsl(self, dsl: str):
|
|
596
|
-
"""Load Graphite DSL"""
|
|
597
|
-
lines = dsl.strip().split('\n')
|
|
598
|
-
i = 0
|
|
599
|
-
|
|
600
|
-
while i < len(lines):
|
|
601
|
-
line = lines[i].strip()
|
|
602
|
-
if not line or line.startswith('#'):
|
|
603
|
-
i += 1
|
|
604
|
-
continue
|
|
605
|
-
|
|
606
|
-
if line.startswith('node'):
|
|
607
|
-
# Collect multiline node definition
|
|
608
|
-
node_def = [line]
|
|
609
|
-
i += 1
|
|
610
|
-
while (
|
|
611
|
-
i < len(lines)
|
|
612
|
-
and lines[i].strip()
|
|
613
|
-
and not lines[i].strip().startswith(('node', 'relation'))
|
|
614
|
-
):
|
|
615
|
-
node_def.append(lines[i])
|
|
616
|
-
i += 1
|
|
617
|
-
self.define_node('\n'.join(node_def))
|
|
618
|
-
|
|
619
|
-
elif line.startswith('relation'):
|
|
620
|
-
# Collect multiline relation definition
|
|
621
|
-
rel_def = [line]
|
|
622
|
-
i += 1
|
|
623
|
-
while (
|
|
624
|
-
i < len(lines)
|
|
625
|
-
and lines[i].strip()
|
|
626
|
-
and not lines[i].strip().startswith(('node', 'relation'))
|
|
627
|
-
):
|
|
628
|
-
rel_def.append(lines[i])
|
|
629
|
-
i += 1
|
|
630
|
-
self.define_relation('\n'.join(rel_def))
|
|
631
|
-
|
|
632
|
-
elif '[' not in line:
|
|
633
|
-
# Node instance
|
|
634
|
-
node_type, node_id, values = self.parser.parse_node_instance(line)
|
|
635
|
-
self.create_node(node_type, node_id, *values)
|
|
636
|
-
i += 1
|
|
637
|
-
|
|
638
|
-
elif '-[' in line and (']->' in line or ']-' in line):
|
|
639
|
-
# Relation instance
|
|
640
|
-
from_id, to_id, rel_type, values, _ = self.parser.parse_relation_instance(line)
|
|
641
|
-
self.create_relation(from_id, to_id, rel_type, *values)
|
|
642
|
-
i += 1
|
|
643
|
-
else:
|
|
644
|
-
i += 1
|
|
645
|
-
|
|
646
|
-
# =============== PERSISTENCE ===============
|
|
647
|
-
|
|
648
|
-
def save(self, filename: str):
|
|
649
|
-
"""Save database to file"""
|
|
650
|
-
with open(filename, 'wb') as f:
|
|
651
|
-
data = {
|
|
652
|
-
'node_types' : self.node_types,
|
|
653
|
-
'relation_types' : self.relation_types,
|
|
654
|
-
'nodes' : self.nodes,
|
|
655
|
-
'relations' : self.relations,
|
|
656
|
-
'node_by_type' : self.node_by_type,
|
|
657
|
-
'relations_by_type': self.relations_by_type,
|
|
658
|
-
'relations_by_from': self.relations_by_from,
|
|
659
|
-
'relations_by_to' : self.relations_by_to,
|
|
660
|
-
}
|
|
661
|
-
# noinspection PyTypeChecker
|
|
662
|
-
pickle.dump(data, f)
|
|
663
|
-
|
|
664
|
-
def load(self, filename: str):
|
|
665
|
-
"""Load database from file"""
|
|
666
|
-
with open(filename, 'rb') as f:
|
|
667
|
-
data = pickle.load(f)
|
|
668
|
-
self.node_types = data['node_types']
|
|
669
|
-
self.relation_types = data['relation_types']
|
|
670
|
-
self.nodes = data['nodes']
|
|
671
|
-
self.relations = data['relations']
|
|
672
|
-
self.node_by_type = data['node_by_type']
|
|
673
|
-
self.relations_by_type = data['relations_by_type']
|
|
674
|
-
self.relations_by_from = data['relations_by_from']
|
|
675
|
-
self.relations_by_to = data['relations_by_to']
|
|
676
|
-
|
|
677
|
-
# =============== UTILITY METHODS ===============
|
|
678
|
-
|
|
679
|
-
def clear(self):
|
|
680
|
-
"""Clear all data"""
|
|
681
|
-
self.node_types.clear()
|
|
682
|
-
self.relation_types.clear()
|
|
683
|
-
self.nodes.clear()
|
|
684
|
-
self.relations.clear()
|
|
685
|
-
self.node_by_type.clear()
|
|
686
|
-
self.relations_by_type.clear()
|
|
687
|
-
self.relations_by_from.clear()
|
|
688
|
-
self.relations_by_to.clear()
|
|
689
|
-
|
|
690
|
-
def stats(self) -> Dict[str, Any]:
|
|
691
|
-
"""Get database statistics"""
|
|
692
|
-
return {
|
|
693
|
-
'node_types' : len(self.node_types),
|
|
694
|
-
'relation_types': len(self.relation_types),
|
|
695
|
-
'nodes' : len(self.nodes),
|
|
696
|
-
'relations' : len(self.relations),
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
# =============== SYNTAX SUGAR ===============
|
|
700
|
-
|
|
701
|
-
def parse(self, data: str):
|
|
702
|
-
"""Parse data into nodes and relations (strcuture or data)"""
|
|
703
|
-
self.load_dsl(data)
|
|
704
|
-
|
|
705
|
-
# =============== SYNTAX SUGAR ===============
|
|
706
|
-
|
|
707
|
-
def node(node_type: str, **fields) -> str:
|
|
708
|
-
"""Helper function to create node definitions"""
|
|
709
|
-
lines = [f"node {node_type}"]
|
|
710
|
-
for field_name, field_type in fields.items():
|
|
711
|
-
lines.append(f"{field_name}: {field_type}")
|
|
712
|
-
return "\n".join(lines)
|
|
713
|
-
|
|
714
|
-
def relation(name: str, from_type: str, to_type: str, **kwargs) -> str:
|
|
715
|
-
"""Helper function to create relation definitions"""
|
|
716
|
-
lines = [f"relation {name}"]
|
|
717
|
-
if kwargs.get('both'):
|
|
718
|
-
lines[0] += " both"
|
|
719
|
-
if kwargs.get('reverse'):
|
|
720
|
-
lines[0] += f" reverse {kwargs['reverse']}"
|
|
721
|
-
|
|
722
|
-
direction = "->" if not kwargs.get('both') else "-"
|
|
723
|
-
lines.append(f"{from_type} {direction} {to_type}")
|
|
724
|
-
|
|
725
|
-
for field_name, field_type in kwargs.get('fields', {}).items():
|
|
726
|
-
lines.append(f"{field_name}: {field_type}")
|
|
727
|
-
|
|
728
|
-
return "\n".join(lines)
|
|
729
|
-
|
|
730
|
-
# ================ PUBLIC API ================
|
|
731
|
-
|
|
732
|
-
def engine() -> GraphiteEngine:
|
|
733
|
-
"""Create graphite engine instance"""
|
|
734
|
-
return GraphiteEngine()
|
|
7
|
+
from warnings import simplefilter
|
|
8
|
+
|
|
9
|
+
from .types import DataType, Field, NodeType, RelationType
|
|
10
|
+
from .instances import Node, Relation
|
|
11
|
+
from .serialization import GraphiteJSONEncoder
|
|
12
|
+
from .parser import GraphiteParser
|
|
13
|
+
from .query import QueryResult, QueryBuilder
|
|
14
|
+
from .engine import GraphiteEngine
|
|
15
|
+
from .migration import Migration
|
|
16
|
+
from .utils import node, relation, engine, SecurityWarning
|
|
17
|
+
|
|
18
|
+
simplefilter('always', SecurityWarning)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
'DataType', 'Field', 'NodeType', 'RelationType',
|
|
22
|
+
'Node', 'Relation', 'GraphiteJSONEncoder',
|
|
23
|
+
'GraphiteParser', 'QueryResult', 'QueryBuilder',
|
|
24
|
+
'GraphiteEngine', 'Migration', 'SecurityWarning',
|
|
25
|
+
'node', 'relation', 'engine'
|
|
26
|
+
]
|