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/migration.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helper module to update Graphite databases and handle other migrations
|
|
3
|
+
"""
|
|
4
|
+
import warnings
|
|
5
|
+
import pickle
|
|
6
|
+
import os
|
|
7
|
+
import glob
|
|
8
|
+
from .engine import GraphiteEngine
|
|
9
|
+
from .utils import SecurityWarning
|
|
10
|
+
|
|
11
|
+
class Migration:
|
|
12
|
+
"""Utility for migrating from older versions"""
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def convert_pickle_to_json(
|
|
16
|
+
pickle_file: str, json_file: str, delete_original: bool = False
|
|
17
|
+
) -> bool:
|
|
18
|
+
"""
|
|
19
|
+
Convert a pickle file to JSON format
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
pickle_file: Path to existing pickle file
|
|
23
|
+
json_file: Path for new JSON file
|
|
24
|
+
delete_original: Whether to delete pickle file after conversion
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
True if successful, False otherwise
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
warnings.warn(
|
|
31
|
+
"'convert_pickle_to_json' will be deprecated because of security reasons. "
|
|
32
|
+
"Please convert your pickle files to JSON and don't use old files anymore.",
|
|
33
|
+
PendingDeprecationWarning
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Load from pickle (with safety warnings)
|
|
37
|
+
warnings.warn(
|
|
38
|
+
f"Loading from pickle file: {pickle_file}. "
|
|
39
|
+
"Pickle files can contain malicious code. "
|
|
40
|
+
"Only load files from trusted sources.",
|
|
41
|
+
SecurityWarning
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
with open(pickle_file, 'rb') as f:
|
|
45
|
+
data = pickle.load(f)
|
|
46
|
+
|
|
47
|
+
# Create a new engine with the loaded data
|
|
48
|
+
converter_engine = GraphiteEngine()
|
|
49
|
+
|
|
50
|
+
# Restore data structures
|
|
51
|
+
converter_engine.node_types = data['node_types']
|
|
52
|
+
converter_engine.relation_types = data['relation_types']
|
|
53
|
+
converter_engine.nodes = data['nodes']
|
|
54
|
+
converter_engine.relations = data['relations']
|
|
55
|
+
converter_engine.node_by_type = data['node_by_type']
|
|
56
|
+
converter_engine.relations_by_type = data['relations_by_type']
|
|
57
|
+
converter_engine.relations_by_from = data['relations_by_from']
|
|
58
|
+
converter_engine.relations_by_to = data['relations_by_to']
|
|
59
|
+
|
|
60
|
+
# Save to JSON
|
|
61
|
+
converter_engine.save(json_file)
|
|
62
|
+
|
|
63
|
+
if delete_original:
|
|
64
|
+
os.unlink(pickle_file)
|
|
65
|
+
print(f"Converted {pickle_file} to {json_file} and deleted original")
|
|
66
|
+
else:
|
|
67
|
+
print(f"Converted {pickle_file} to {json_file}")
|
|
68
|
+
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
72
|
+
print(f"Conversion failed: {e}")
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def detect_pickle_and_convert_to_json(
|
|
77
|
+
directory: str, pattern: str = "*.db", delete_originals: bool = False
|
|
78
|
+
):
|
|
79
|
+
"""
|
|
80
|
+
Find and convert all pickle files in a directory
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
directory: Directory to scan
|
|
84
|
+
pattern: File pattern to match (default: *.db)
|
|
85
|
+
delete_originals: Whether to delete pickle files after conversion
|
|
86
|
+
"""
|
|
87
|
+
for pickle_file in glob.glob(os.path.join(directory, pattern)):
|
|
88
|
+
if pickle_file.endswith('.json'):
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
json_file = pickle_file.rsplit('.', 1)[0] + '.json'
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
# Quick check if it's a pickle file
|
|
95
|
+
with open(pickle_file, 'rb') as f:
|
|
96
|
+
# Try to read pickle header
|
|
97
|
+
header = f.read(4)
|
|
98
|
+
if header == b'\x80\x04' or header.startswith(b'\x80'): # Pickle protocol 4
|
|
99
|
+
Migration.convert_pickle_to_json(
|
|
100
|
+
pickle_file, json_file, delete_originals
|
|
101
|
+
)
|
|
102
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
103
|
+
# Not a pickle file or can't read
|
|
104
|
+
print(f"File '{pickle_file}' skipped: {e}")
|
graphite/parser.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parser for Graphite DSL
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import Any, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from .exceptions import DateParseError, FieldError, NotFoundError, SchemaError
|
|
9
|
+
from .types import DataType, Field
|
|
10
|
+
|
|
11
|
+
class GraphiteParser:
|
|
12
|
+
"""Parser for Graphite DSL"""
|
|
13
|
+
|
|
14
|
+
def parse_field_value(self, value: Any, field: Field) -> Any:
|
|
15
|
+
"""
|
|
16
|
+
Parse a raw value for a field (node or relation) and return it.
|
|
17
|
+
|
|
18
|
+
**Note:** Value will be validated with field information, use ``parse_value()``
|
|
19
|
+
to ignore validation.
|
|
20
|
+
"""
|
|
21
|
+
value = self.parse_value(value)
|
|
22
|
+
return self.validate_field_value(value, field)
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
# pylint: disable=broad-exception-caught, too-many-branches
|
|
26
|
+
def validate_field_value(value: Any, field: Field) -> Any:
|
|
27
|
+
"""
|
|
28
|
+
Converts given value to field's data type. Raises ``FieldError`` at fail.
|
|
29
|
+
"""
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
exc = None
|
|
33
|
+
if field.dtype == DataType.STRING and not isinstance(value, str):
|
|
34
|
+
try:
|
|
35
|
+
value = str(exc)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
exc = e
|
|
38
|
+
elif field.dtype == DataType.INT and not isinstance(value, int):
|
|
39
|
+
try:
|
|
40
|
+
value = int(value)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
exc = e
|
|
43
|
+
elif field.dtype == DataType.DATE and not isinstance(value, (datetime, date)):
|
|
44
|
+
try:
|
|
45
|
+
value = datetime.strptime(value, "%Y-%m-%d").date()
|
|
46
|
+
except Exception as e:
|
|
47
|
+
exc = e
|
|
48
|
+
elif field.dtype == DataType.FLOAT and not isinstance(value, float):
|
|
49
|
+
try:
|
|
50
|
+
value = float(value)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
exc = e
|
|
53
|
+
elif field.dtype == DataType.BOOL and not isinstance(value, bool):
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
value = value.lower() == "true"
|
|
56
|
+
else:
|
|
57
|
+
try:
|
|
58
|
+
value = bool(value)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
exc = e
|
|
61
|
+
elif field.dtype not in DataType:
|
|
62
|
+
raise NotFoundError(
|
|
63
|
+
"Data type",
|
|
64
|
+
str(field.dtype)
|
|
65
|
+
)
|
|
66
|
+
if exc is not None:
|
|
67
|
+
raise FieldError(
|
|
68
|
+
field,
|
|
69
|
+
value
|
|
70
|
+
) from exc
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
# pylint: disable=too-many-return-statements
|
|
75
|
+
def parse_value(value: Any) -> Any:
|
|
76
|
+
"""Parses a raw value (usually ``str``) into correct type."""
|
|
77
|
+
if not isinstance(value, str):
|
|
78
|
+
return value
|
|
79
|
+
value = value.strip()
|
|
80
|
+
if value.startswith('"') and value.endswith('"'):
|
|
81
|
+
return value[1:-1]
|
|
82
|
+
if value.replace('-', '').isdigit() and value.count("-") == 2: # Date-like
|
|
83
|
+
try:
|
|
84
|
+
return datetime.strptime(value, '%Y-%m-%d').date()
|
|
85
|
+
except ValueError as e:
|
|
86
|
+
raise DateParseError(value) from e
|
|
87
|
+
if value.isdigit() or (value.startswith('-') and value[1:].isdigit()):
|
|
88
|
+
return int(value)
|
|
89
|
+
if value.replace('.', '').isdigit() and value.count('.') == 1:
|
|
90
|
+
return float(value)
|
|
91
|
+
if value.lower() in ('true', 'false'):
|
|
92
|
+
return value.lower() == 'true'
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def parse_node_definition(line: str) -> Tuple[str, List[Field], str]:
|
|
97
|
+
"""Parse node type definition: 'node Person\nname: string\nage: int'"""
|
|
98
|
+
lines = line.strip().split('\n')
|
|
99
|
+
first_line = lines[0].strip()
|
|
100
|
+
|
|
101
|
+
# Parse inheritance
|
|
102
|
+
if ' from ' in first_line:
|
|
103
|
+
parts = first_line.split(' from ')
|
|
104
|
+
node_name = parts[0].replace('node', '').strip()
|
|
105
|
+
parent = parts[1].strip()
|
|
106
|
+
fields_start = 1
|
|
107
|
+
else:
|
|
108
|
+
node_name = first_line.replace('node', '').strip()
|
|
109
|
+
parent = None
|
|
110
|
+
fields_start = 1
|
|
111
|
+
|
|
112
|
+
fields = []
|
|
113
|
+
for field_line in lines[fields_start:]:
|
|
114
|
+
field_line = field_line.strip()
|
|
115
|
+
if not field_line:
|
|
116
|
+
continue
|
|
117
|
+
name_type = field_line.split(':')
|
|
118
|
+
if len(name_type) == 2:
|
|
119
|
+
name = name_type[0].strip()
|
|
120
|
+
dtype_str = name_type[1].strip()
|
|
121
|
+
dtype = DataType(dtype_str)
|
|
122
|
+
fields.append(Field(name, dtype))
|
|
123
|
+
|
|
124
|
+
return node_name, fields, parent
|
|
125
|
+
|
|
126
|
+
# pylint: disable=too-many-locals
|
|
127
|
+
@staticmethod
|
|
128
|
+
def parse_relation_definition(line: str) -> Tuple[str, str, str, List[Field], Optional[str], bool]:
|
|
129
|
+
"""Parse relation definition"""
|
|
130
|
+
lines = line.strip().split('\n')
|
|
131
|
+
first_line = lines[0].strip()
|
|
132
|
+
|
|
133
|
+
# Check for 'both' keyword
|
|
134
|
+
is_bidirectional = ' both' in first_line
|
|
135
|
+
if is_bidirectional:
|
|
136
|
+
first_line = first_line.replace(' both', '')
|
|
137
|
+
|
|
138
|
+
# Parse reverse
|
|
139
|
+
reverse_name = None
|
|
140
|
+
if ' reverse ' in first_line:
|
|
141
|
+
parts = first_line.split(' reverse ')
|
|
142
|
+
relation_name = parts[0].replace('relation', '').strip()
|
|
143
|
+
reverse_name = parts[1].strip()
|
|
144
|
+
else:
|
|
145
|
+
relation_name = first_line.replace('relation', '').strip()
|
|
146
|
+
|
|
147
|
+
# Parse participants
|
|
148
|
+
participants_line = lines[1].strip()
|
|
149
|
+
if '->' in participants_line:
|
|
150
|
+
from_to = participants_line.split('->')
|
|
151
|
+
from_type = from_to[0].strip()
|
|
152
|
+
to_type = from_to[1].strip()
|
|
153
|
+
elif '-' in participants_line:
|
|
154
|
+
parts = participants_line.split('-')
|
|
155
|
+
from_type = parts[0].strip()
|
|
156
|
+
to_type = parts[2].strip() if len(parts) > 2 else parts[1].strip()
|
|
157
|
+
else:
|
|
158
|
+
raise SchemaError(f"Invalid relation type format: {participants_line}")
|
|
159
|
+
|
|
160
|
+
# Parse fields
|
|
161
|
+
fields = []
|
|
162
|
+
for field_line in lines[2:]:
|
|
163
|
+
field_line = field_line.strip()
|
|
164
|
+
if not field_line:
|
|
165
|
+
continue
|
|
166
|
+
name_type = field_line.split(':')
|
|
167
|
+
if len(name_type) == 2:
|
|
168
|
+
name = name_type[0].strip()
|
|
169
|
+
dtype_str = name_type[1].strip()
|
|
170
|
+
dtype = DataType(dtype_str)
|
|
171
|
+
fields.append(Field(name, dtype))
|
|
172
|
+
|
|
173
|
+
return relation_name, from_type, to_type, fields, reverse_name, is_bidirectional
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def parse_node_instance(line: str) -> Tuple[str, str, List[Any]]:
|
|
177
|
+
"""Parse node instance: 'User, user_1, "Joe Doe", 32, "joe4030"'"""
|
|
178
|
+
# Handle quoted strings
|
|
179
|
+
parts = []
|
|
180
|
+
current = ''
|
|
181
|
+
in_quotes = False
|
|
182
|
+
for char in line:
|
|
183
|
+
if char == '"':
|
|
184
|
+
in_quotes = not in_quotes
|
|
185
|
+
current += char
|
|
186
|
+
elif char == ',' and not in_quotes:
|
|
187
|
+
parts.append(current.strip())
|
|
188
|
+
current = ''
|
|
189
|
+
else:
|
|
190
|
+
current += char
|
|
191
|
+
if current:
|
|
192
|
+
parts.append(current.strip())
|
|
193
|
+
|
|
194
|
+
node_type = parts[0].strip()
|
|
195
|
+
node_id = parts[1].strip()
|
|
196
|
+
values = list(map(GraphiteParser.parse_value, parts[2:]))
|
|
197
|
+
|
|
198
|
+
return node_type, node_id, values
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def parse_relation_instance(
|
|
202
|
+
line: str
|
|
203
|
+
) -> tuple[Union[str, Any], Union[str, Any], Any, list[Any], str]:
|
|
204
|
+
"""Parse relation instance: 'user_1 -[OWNER, 2000-10-04]-> notebook'"""
|
|
205
|
+
# Extract relation type and attributes
|
|
206
|
+
pattern = r'(\w+)\s*(-\[([^\]]+)\]\s*[->-]\s*|\s*[->-]\s*\[([^\]]+)\]\s*->\s*)(\w+)'
|
|
207
|
+
match = re.search(pattern, line)
|
|
208
|
+
if not match:
|
|
209
|
+
raise SchemaError(f"Invalid relation format: {line}")
|
|
210
|
+
|
|
211
|
+
from_node = match.group(1)
|
|
212
|
+
to_node = match.group(5)
|
|
213
|
+
|
|
214
|
+
# Get relation type and attributes
|
|
215
|
+
rel_part = match.group(3) or match.group(4)
|
|
216
|
+
rel_parts = [p.strip() for p in rel_part.split(',')]
|
|
217
|
+
rel_type = rel_parts[0]
|
|
218
|
+
attributes = list(map(GraphiteParser.parse_value, rel_parts[1:]) if len(rel_parts) > 1 else [])
|
|
219
|
+
|
|
220
|
+
# Parse direction
|
|
221
|
+
if '->' in line:
|
|
222
|
+
direction = 'forward'
|
|
223
|
+
elif '-[' in line and ']-' in line:
|
|
224
|
+
direction = 'bidirectional'
|
|
225
|
+
else:
|
|
226
|
+
direction = 'forward'
|
|
227
|
+
|
|
228
|
+
return from_node, to_node, rel_type, attributes, direction
|
graphite/query.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query engine and object for Graphite
|
|
3
|
+
"""
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from typing import TYPE_CHECKING, List, Callable, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .instances import Node, Relation
|
|
8
|
+
from .types import RelationType
|
|
9
|
+
from .exceptions import ConditionError, DateParseError, NotFoundError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .engine import GraphiteEngine
|
|
13
|
+
|
|
14
|
+
class QueryResult:
|
|
15
|
+
"""Represents a query result that can be chained"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, graph_engine: 'GraphiteEngine', nodes: List[Node], edges: List[Relation] = None
|
|
19
|
+
):
|
|
20
|
+
self.engine = graph_engine
|
|
21
|
+
self.nodes = nodes
|
|
22
|
+
self.edges = edges or []
|
|
23
|
+
self.current_relation: Optional[RelationType] = None
|
|
24
|
+
self.direction: str = 'outgoing'
|
|
25
|
+
|
|
26
|
+
def where(self, condition: Union[str, Callable]):
|
|
27
|
+
"""Filter nodes based on condition"""
|
|
28
|
+
filtered_nodes = []
|
|
29
|
+
|
|
30
|
+
if callable(condition):
|
|
31
|
+
# Lambda function
|
|
32
|
+
for processing_node in self.nodes:
|
|
33
|
+
try:
|
|
34
|
+
if condition(processing_node):
|
|
35
|
+
filtered_nodes.append(processing_node)
|
|
36
|
+
except Exception as e:
|
|
37
|
+
raise ConditionError(str(condition)) from e
|
|
38
|
+
else:
|
|
39
|
+
# String condition like "age > 18"
|
|
40
|
+
for processing_node in self.nodes:
|
|
41
|
+
if self._evaluate_condition(processing_node, condition):
|
|
42
|
+
filtered_nodes.append(processing_node)
|
|
43
|
+
|
|
44
|
+
return QueryResult(self.engine, filtered_nodes, self.edges)
|
|
45
|
+
|
|
46
|
+
# pylint: disable=too-many-branches
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _evaluate_condition(target_node: Node, condition: str) -> bool:
|
|
49
|
+
"""Evaluate a condition string on a node"""
|
|
50
|
+
# Simple condition parser
|
|
51
|
+
ops = ['>=', '<=', '!=', '==', '>', '<', '=']
|
|
52
|
+
|
|
53
|
+
for op in ops:
|
|
54
|
+
if op in condition:
|
|
55
|
+
left, right = condition.split(op)
|
|
56
|
+
left = left.strip()
|
|
57
|
+
right = right.strip()
|
|
58
|
+
|
|
59
|
+
# Get value from node
|
|
60
|
+
node_value = target_node.get(left)
|
|
61
|
+
if node_value is None:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Parse right side
|
|
65
|
+
if right[0] in ('"', "'") and right[-1] in ('"', "'"):
|
|
66
|
+
right_value = right[1:-1]
|
|
67
|
+
elif right.isdigit():
|
|
68
|
+
right_value = int(right)
|
|
69
|
+
elif right.replace('.', '').isdigit() and right.count('.') == 1:
|
|
70
|
+
right_value = float(right)
|
|
71
|
+
else:
|
|
72
|
+
right_value = right
|
|
73
|
+
|
|
74
|
+
if right_value == "true":
|
|
75
|
+
right_value = True
|
|
76
|
+
elif right_value == "false":
|
|
77
|
+
right_value = False
|
|
78
|
+
|
|
79
|
+
if isinstance(node_value, date):
|
|
80
|
+
try:
|
|
81
|
+
right_value = datetime.strptime(right_value, "%Y-%m-%d").date()
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise DateParseError(right_value) from e
|
|
84
|
+
|
|
85
|
+
# Apply operation
|
|
86
|
+
result = None
|
|
87
|
+
try:
|
|
88
|
+
if op in ('=', '=='):
|
|
89
|
+
result = node_value == right_value
|
|
90
|
+
if op == '!=':
|
|
91
|
+
result = node_value != right_value
|
|
92
|
+
if op == '>':
|
|
93
|
+
result = node_value > right_value
|
|
94
|
+
if op == '<':
|
|
95
|
+
result = node_value < right_value
|
|
96
|
+
if op == '>=':
|
|
97
|
+
result = node_value >= right_value
|
|
98
|
+
if op == '<=':
|
|
99
|
+
result = node_value <= right_value
|
|
100
|
+
except TypeError as e:
|
|
101
|
+
raise e
|
|
102
|
+
if result is None:
|
|
103
|
+
raise ConditionError(condition)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
raise ConditionError(condition)
|
|
107
|
+
|
|
108
|
+
def traverse(self, relation_type: str, direction: str = 'outgoing'):
|
|
109
|
+
"""Traverse relations from current nodes"""
|
|
110
|
+
result_nodes = []
|
|
111
|
+
result_edges = []
|
|
112
|
+
|
|
113
|
+
for processing_node in self.nodes:
|
|
114
|
+
if direction == 'outgoing':
|
|
115
|
+
edges = self.engine.get_relations_from(processing_node.id, relation_type)
|
|
116
|
+
elif direction == 'incoming':
|
|
117
|
+
edges = self.engine.get_relations_to(processing_node.id, relation_type)
|
|
118
|
+
else: # both
|
|
119
|
+
edges = (self.engine.get_relations_from(processing_node.id, relation_type) +
|
|
120
|
+
self.engine.get_relations_to(processing_node.id, relation_type))
|
|
121
|
+
|
|
122
|
+
for edge in edges:
|
|
123
|
+
result_edges.append(edge)
|
|
124
|
+
target_id = edge.to_node if direction == 'outgoing' else edge.from_node
|
|
125
|
+
target_node = self.engine.get_node(target_id)
|
|
126
|
+
if target_node:
|
|
127
|
+
result_nodes.append(target_node)
|
|
128
|
+
|
|
129
|
+
# Remove duplicates
|
|
130
|
+
result_nodes = list(dict((n.id, n) for n in result_nodes).values())
|
|
131
|
+
return QueryResult(self.engine, result_nodes, result_edges)
|
|
132
|
+
|
|
133
|
+
def outgoing(self, relation_type: str):
|
|
134
|
+
"""Traverse outgoing relations"""
|
|
135
|
+
return self.traverse(relation_type, 'outgoing')
|
|
136
|
+
|
|
137
|
+
def incoming(self, relation_type: str):
|
|
138
|
+
"""Traverse incoming relations"""
|
|
139
|
+
return self.traverse(relation_type, 'incoming')
|
|
140
|
+
|
|
141
|
+
def both(self, relation_type: str):
|
|
142
|
+
"""Traverse both directions"""
|
|
143
|
+
return self.traverse(relation_type, 'both')
|
|
144
|
+
|
|
145
|
+
def limit(self, n: int):
|
|
146
|
+
"""Limit number of results"""
|
|
147
|
+
return QueryResult(self.engine, self.nodes[:n], self.edges[:n])
|
|
148
|
+
|
|
149
|
+
def distinct(self):
|
|
150
|
+
"""Get distinct nodes"""
|
|
151
|
+
seen = set()
|
|
152
|
+
distinct_nodes = []
|
|
153
|
+
for processing_node in self.nodes:
|
|
154
|
+
if processing_node.id not in seen:
|
|
155
|
+
seen.add(processing_node.id)
|
|
156
|
+
distinct_nodes.append(processing_node)
|
|
157
|
+
return QueryResult(self.engine, distinct_nodes, self.edges)
|
|
158
|
+
|
|
159
|
+
def order_by(self, by_field: str, descending: bool = False):
|
|
160
|
+
"""Order nodes by field"""
|
|
161
|
+
|
|
162
|
+
def get_key(from_node):
|
|
163
|
+
val = from_node.get(by_field)
|
|
164
|
+
return val is None, val
|
|
165
|
+
|
|
166
|
+
sorted_nodes = sorted(self.nodes, key=get_key, reverse=descending)
|
|
167
|
+
return QueryResult(self.engine, sorted_nodes, self.edges)
|
|
168
|
+
|
|
169
|
+
def count(self) -> int:
|
|
170
|
+
"""Count nodes"""
|
|
171
|
+
return len(self.nodes)
|
|
172
|
+
|
|
173
|
+
def get(self) -> List[Node]:
|
|
174
|
+
"""Get all nodes"""
|
|
175
|
+
return self.nodes
|
|
176
|
+
|
|
177
|
+
def first(self) -> Optional[Node]:
|
|
178
|
+
"""Get first node"""
|
|
179
|
+
return self.nodes[0] if self.nodes else None
|
|
180
|
+
|
|
181
|
+
def ids(self) -> List[str]:
|
|
182
|
+
"""Get node IDs"""
|
|
183
|
+
return [n.id for n in self.nodes]
|
|
184
|
+
|
|
185
|
+
class QueryBuilder: # pylint: disable=too-few-public-methods
|
|
186
|
+
"""Builder for creating queries"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, graphite_engine: 'GraphiteEngine'):
|
|
189
|
+
self.engine = graphite_engine
|
|
190
|
+
|
|
191
|
+
def __getattr__(self, name: str) -> QueryResult:
|
|
192
|
+
"""Allow starting query from node type: engine.User"""
|
|
193
|
+
if name in self.engine.node_types:
|
|
194
|
+
nodes = self.engine.get_nodes_of_type(name)
|
|
195
|
+
return QueryResult(self.engine, nodes)
|
|
196
|
+
raise NotFoundError(
|
|
197
|
+
"Node type",
|
|
198
|
+
name
|
|
199
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serialization utils for Graphite databases
|
|
3
|
+
"""
|
|
4
|
+
import json
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import asdict, is_dataclass
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Callable, Union
|
|
10
|
+
|
|
11
|
+
from .instances import Node, Relation
|
|
12
|
+
from .types import DataType, Field, NodeType, RelationType
|
|
13
|
+
|
|
14
|
+
GRAPHITE_TYPE_FIELD = "__graphite_type__"
|
|
15
|
+
DEFAULT_FACTORY_FIELD = "__default_factory"
|
|
16
|
+
|
|
17
|
+
def _serialize_instance(instance: Union[Node, Relation]) -> dict[str, Any]:
|
|
18
|
+
return {
|
|
19
|
+
GRAPHITE_TYPE_FIELD: type(instance).__name__,
|
|
20
|
+
"type_name" : instance.type_name,
|
|
21
|
+
"id" : instance.id if hasattr(instance, "id") else None,
|
|
22
|
+
"values" : instance.values,
|
|
23
|
+
"from_node" : instance.from_node if hasattr(instance, "from_node") else None,
|
|
24
|
+
"to_node" : instance.to_node if hasattr(instance, "to_node") else None,
|
|
25
|
+
"type_ref" : instance.type_ref.name if instance.type_ref else None
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class GraphiteJSONEncoder(json.JSONEncoder):
|
|
29
|
+
"""Custom JSON encoder for Graphite data structures"""
|
|
30
|
+
|
|
31
|
+
def default(self, o: Any) -> Any: # pylint: disable=too-many-return-statements
|
|
32
|
+
# Handle date/datetime objects
|
|
33
|
+
if isinstance(o, (date, datetime)):
|
|
34
|
+
return {
|
|
35
|
+
GRAPHITE_TYPE_FIELD: "datetime",
|
|
36
|
+
"value" : o.isoformat(),
|
|
37
|
+
"is_date" : isinstance(o, date)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Handle DataType enum specifically (must come before Enum)
|
|
41
|
+
if isinstance(o, DataType):
|
|
42
|
+
return {
|
|
43
|
+
GRAPHITE_TYPE_FIELD: "datatype",
|
|
44
|
+
"value" : o.value
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Handle Enum objects
|
|
48
|
+
if isinstance(o, Enum):
|
|
49
|
+
return {
|
|
50
|
+
GRAPHITE_TYPE_FIELD: "enum",
|
|
51
|
+
"enum_class" : type(o).__name__,
|
|
52
|
+
"value" : o.value
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Handle defaultdict
|
|
56
|
+
if isinstance(o, defaultdict):
|
|
57
|
+
result = dict(o)
|
|
58
|
+
result[GRAPHITE_TYPE_FIELD] = "defaultdict"
|
|
59
|
+
result[DEFAULT_FACTORY_FIELD] = o.default_factory.__name__ if o.default_factory else None
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
# Handle Node and Relation instances (already dataclasses but need special handling)
|
|
63
|
+
if isinstance(o, (Node, Relation)):
|
|
64
|
+
# Convert to dict with minimal information
|
|
65
|
+
return _serialize_instance(o)
|
|
66
|
+
|
|
67
|
+
# Handle NodeType and RelationType (dataclasses with parent references)
|
|
68
|
+
if isinstance(o, (NodeType, RelationType)):
|
|
69
|
+
result = asdict(o)
|
|
70
|
+
result[GRAPHITE_TYPE_FIELD] = type(o).__name__
|
|
71
|
+
# Convert parent to name reference to avoid circular references
|
|
72
|
+
if isinstance(o, NodeType) and o.parent:
|
|
73
|
+
result["parent"] = o.parent.name
|
|
74
|
+
# Remove type_ref from serialization
|
|
75
|
+
result.pop("type_ref", None)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
# Handle Field
|
|
79
|
+
if isinstance(o, Field):
|
|
80
|
+
result = asdict(o)
|
|
81
|
+
result[GRAPHITE_TYPE_FIELD] = "Field"
|
|
82
|
+
# Convert dtype to value
|
|
83
|
+
result["dtype"] = o.dtype.value
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
# Handle dataclasses
|
|
87
|
+
if is_dataclass(o) and not isinstance(o, type):
|
|
88
|
+
# Convert to dict and add type info
|
|
89
|
+
result = asdict(o)
|
|
90
|
+
result[GRAPHITE_TYPE_FIELD] = type(o).__name__
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
if isinstance(o, (dict, list)):
|
|
94
|
+
return o
|
|
95
|
+
|
|
96
|
+
return super().default(o)
|
|
97
|
+
|
|
98
|
+
# pylint: disable=too-many-branches
|
|
99
|
+
def graphite_object_hook(dct: dict[str, Any]) -> Any: # pylint: disable=too-many-return-statements
|
|
100
|
+
"""Decode Graphite-specific objects from JSON."""
|
|
101
|
+
if GRAPHITE_TYPE_FIELD not in dct:
|
|
102
|
+
return dct
|
|
103
|
+
|
|
104
|
+
graphite_type = dct.pop(GRAPHITE_TYPE_FIELD)
|
|
105
|
+
|
|
106
|
+
if graphite_type == "datetime":
|
|
107
|
+
value = dct["value"]
|
|
108
|
+
if dct.get("is_date"):
|
|
109
|
+
return date.fromisoformat(value)
|
|
110
|
+
return datetime.fromisoformat(value)
|
|
111
|
+
|
|
112
|
+
if graphite_type == "enum":
|
|
113
|
+
enum_class = dct["enum_class"]
|
|
114
|
+
value = dct["value"]
|
|
115
|
+
if enum_class == "DataType":
|
|
116
|
+
return DataType(value)
|
|
117
|
+
return dct
|
|
118
|
+
|
|
119
|
+
if graphite_type == "datatype":
|
|
120
|
+
return DataType(dct["value"])
|
|
121
|
+
|
|
122
|
+
if graphite_type == "defaultdict":
|
|
123
|
+
factory_name = dct.pop(DEFAULT_FACTORY_FIELD, None)
|
|
124
|
+
factory: Union[Callable[[], Any], None] = None
|
|
125
|
+
if factory_name == "list":
|
|
126
|
+
factory = list
|
|
127
|
+
elif factory_name == "dict":
|
|
128
|
+
factory = dict
|
|
129
|
+
result = defaultdict(factory)
|
|
130
|
+
result.update(dct)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
if graphite_type == "Node":
|
|
134
|
+
return Node(
|
|
135
|
+
type_name=dct["type_name"],
|
|
136
|
+
id=dct["id"],
|
|
137
|
+
values=dct["values"],
|
|
138
|
+
type_ref=None
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if graphite_type == "Relation":
|
|
142
|
+
return Relation(
|
|
143
|
+
type_name=dct["type_name"],
|
|
144
|
+
from_node=dct["from_node"],
|
|
145
|
+
to_node=dct["to_node"],
|
|
146
|
+
values=dct["values"],
|
|
147
|
+
type_ref=None
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if graphite_type == "NodeType":
|
|
151
|
+
return {
|
|
152
|
+
"name": dct["name"],
|
|
153
|
+
"fields": dct.get("fields", []),
|
|
154
|
+
"parent": dct.get("parent")
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if graphite_type == "RelationType":
|
|
158
|
+
return RelationType(
|
|
159
|
+
name=dct["name"],
|
|
160
|
+
from_type=dct["from_type"],
|
|
161
|
+
to_type=dct["to_type"],
|
|
162
|
+
fields=dct.get("fields", []),
|
|
163
|
+
reverse_name=dct.get("reverse_name"),
|
|
164
|
+
is_bidirectional=dct.get("is_bidirectional", False)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if graphite_type == "Field":
|
|
168
|
+
return Field(
|
|
169
|
+
name=dct["name"],
|
|
170
|
+
dtype=DataType(dct["dtype"]),
|
|
171
|
+
default=dct.get("default")
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return dct
|