industrial-model 0.1.9__tar.gz → 0.1.11__tar.gz

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.
Files changed (49) hide show
  1. {industrial_model-0.1.9 → industrial_model-0.1.11}/PKG-INFO +35 -1
  2. {industrial_model-0.1.9 → industrial_model-0.1.11}/README.md +34 -0
  3. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/__init__.py +8 -1
  4. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/cognite_adapters/__init__.py +67 -9
  5. industrial_model-0.1.11/industrial_model/cognite_adapters/aggregation_mapper.py +64 -0
  6. industrial_model-0.1.11/industrial_model/cognite_adapters/models.py +35 -0
  7. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/cognite_adapters/optimizer.py +17 -8
  8. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/cognite_adapters/query_result_mapper.py +54 -24
  9. industrial_model-0.1.11/industrial_model/cognite_adapters/upsert_mapper.py +146 -0
  10. industrial_model-0.1.11/industrial_model/cognite_adapters/view_mapper.py +50 -0
  11. industrial_model-0.1.11/industrial_model/config.py +19 -0
  12. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/engines/async_engine.py +13 -1
  13. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/engines/engine.py +34 -2
  14. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/models/__init__.py +8 -0
  15. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/models/entities.py +44 -0
  16. industrial_model-0.1.11/industrial_model/statements/__init__.py +122 -0
  17. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/utils.py +1 -12
  18. {industrial_model-0.1.9 → industrial_model-0.1.11}/pyproject.toml +1 -1
  19. industrial_model-0.1.11/scripts/format.sh +2 -0
  20. industrial_model-0.1.11/scripts/lint.sh +3 -0
  21. industrial_model-0.1.9/tests/tests_adapter.py → industrial_model-0.1.11/tests/models.py +27 -22
  22. industrial_model-0.1.11/tests/test_upsert_mapper.py +39 -0
  23. industrial_model-0.1.11/tests/tests_adapter.py +31 -0
  24. {industrial_model-0.1.9 → industrial_model-0.1.11}/uv.lock +1 -1
  25. industrial_model-0.1.9/.vscode/settings.json +0 -8
  26. industrial_model-0.1.9/industrial_model/cognite_adapters/view_mapper.py +0 -16
  27. industrial_model-0.1.9/industrial_model/config.py +0 -10
  28. industrial_model-0.1.9/industrial_model/statements/__init__.py +0 -72
  29. industrial_model-0.1.9/scripts/format.sh +0 -2
  30. industrial_model-0.1.9/scripts/lint.sh +0 -3
  31. {industrial_model-0.1.9 → industrial_model-0.1.11}/.gitignore +0 -0
  32. {industrial_model-0.1.9 → industrial_model-0.1.11}/.python-version +0 -0
  33. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/cognite_adapters/filter_mapper.py +0 -0
  34. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/cognite_adapters/query_mapper.py +0 -0
  35. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/cognite_adapters/sort_mapper.py +0 -0
  36. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/cognite_adapters/utils.py +0 -0
  37. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/constants.py +0 -0
  38. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/engines/__init__.py +0 -0
  39. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/models/base.py +0 -0
  40. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/models/schemas.py +0 -0
  41. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/py.typed +0 -0
  42. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/queries/__init__.py +0 -0
  43. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/queries/models.py +0 -0
  44. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/queries/params.py +0 -0
  45. {industrial_model-0.1.9 → industrial_model-0.1.11}/industrial_model/statements/expressions.py +0 -0
  46. {industrial_model-0.1.9 → industrial_model-0.1.11}/scripts/build.sh +0 -0
  47. {industrial_model-0.1.9 → industrial_model-0.1.11}/tests/__init__.py +0 -0
  48. {industrial_model-0.1.9 → industrial_model-0.1.11}/tests/cognite-sdk-config.yaml +0 -0
  49. {industrial_model-0.1.9 → industrial_model-0.1.11}/tests/hubs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: industrial-model
3
- Version: 0.1.9
3
+ Version: 0.1.11
4
4
  Summary: Industrial Model ORM
5
5
  Author-email: Lucas Alves <lucasrosaalves@gmail.com>
6
6
  Classifier: Programming Language :: Python
@@ -181,6 +181,40 @@ statement = select(Person).where(
181
181
  )
182
182
  all_results = engine.query_all_pages(statement)
183
183
 
184
+
185
+ # 7. Data Ingestion
186
+
187
+ from industrial_model import (
188
+ WritableViewInstance # necessary for data ingestion
189
+ )
190
+
191
+
192
+ class WritablePerson(WritableViewInstance):
193
+ name: str
194
+ lives_in: InstanceId
195
+ cars: list[InstanceId]
196
+
197
+ # You need to implement the end_id_factory so the model can build the edge ids automatically.
198
+ def edge_id_factory(
199
+ self, target_node: InstanceId, edge_type: InstanceId
200
+ ) -> InstanceId:
201
+ return InstanceId(
202
+ external_id=f"{self.external_id}-{target_node.external_id}-{edge_type.external_id}",
203
+ space=self.space,
204
+ )
205
+
206
+ statement = select(WritablePerson).where(
207
+ (WritablePerson.external_id == "Lucas")
208
+ )
209
+
210
+ person = engine.query_all_pages(statement)[0]
211
+
212
+ person.lives_in = InstanceId(external_id="br", space="data-space")
213
+ person.cars.clear() # Gonna remove all car edges from the person
214
+
215
+ engine.upsert([person])
216
+
184
217
  ```
185
218
 
219
+
186
220
  ---
@@ -161,6 +161,40 @@ statement = select(Person).where(
161
161
  )
162
162
  all_results = engine.query_all_pages(statement)
163
163
 
164
+
165
+ # 7. Data Ingestion
166
+
167
+ from industrial_model import (
168
+ WritableViewInstance # necessary for data ingestion
169
+ )
170
+
171
+
172
+ class WritablePerson(WritableViewInstance):
173
+ name: str
174
+ lives_in: InstanceId
175
+ cars: list[InstanceId]
176
+
177
+ # You need to implement the end_id_factory so the model can build the edge ids automatically.
178
+ def edge_id_factory(
179
+ self, target_node: InstanceId, edge_type: InstanceId
180
+ ) -> InstanceId:
181
+ return InstanceId(
182
+ external_id=f"{self.external_id}-{target_node.external_id}-{edge_type.external_id}",
183
+ space=self.space,
184
+ )
185
+
186
+ statement = select(WritablePerson).where(
187
+ (WritablePerson.external_id == "Lucas")
188
+ )
189
+
190
+ person = engine.query_all_pages(statement)[0]
191
+
192
+ person.lives_in = InstanceId(external_id="br", space="data-space")
193
+ person.cars.clear() # Gonna remove all car edges from the person
194
+
195
+ engine.upsert([person])
196
+
164
197
  ```
165
198
 
199
+
166
200
  ---
@@ -1,16 +1,21 @@
1
1
  from .config import DataModelId
2
2
  from .engines import AsyncEngine, Engine
3
3
  from .models import (
4
+ AggregationResult,
4
5
  InstanceId,
5
6
  PaginatedResult,
6
7
  TViewInstance,
8
+ TWritableViewInstance,
7
9
  ValidationMode,
8
10
  ViewInstance,
9
11
  ViewInstanceConfig,
12
+ WritableViewInstance,
10
13
  )
11
- from .statements import and_, col, not_, or_, select
14
+ from .statements import aggregate, and_, col, not_, or_, select
12
15
 
13
16
  __all__ = [
17
+ "aggregate",
18
+ "AggregationResult",
14
19
  "and_",
15
20
  "or_",
16
21
  "col",
@@ -20,9 +25,11 @@ __all__ = [
20
25
  "InstanceId",
21
26
  "TViewInstance",
22
27
  "DataModelId",
28
+ "TWritableViewInstance",
23
29
  "ValidationMode",
24
30
  "Engine",
25
31
  "AsyncEngine",
26
32
  "PaginatedResult",
27
33
  "ViewInstanceConfig",
34
+ "WritableViewInstance",
28
35
  ]
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from typing import Any
2
3
 
3
4
  from cognite.client import CogniteClient
@@ -9,15 +10,21 @@ from cognite.client.data_classes.data_modeling.query import (
9
10
  QueryResult as CogniteQueryResult,
10
11
  )
11
12
 
12
- from industrial_model.cognite_adapters.optimizer import QueryOptimizer
13
13
  from industrial_model.config import DataModelId
14
- from industrial_model.models import TViewInstance
15
- from industrial_model.statements import Statement
14
+ from industrial_model.models import (
15
+ AggregationResult,
16
+ TViewInstance,
17
+ TWritableViewInstance,
18
+ )
19
+ from industrial_model.statements import AggregationStatement, Statement
16
20
 
21
+ from .aggregation_mapper import AggregationMapper
22
+ from .optimizer import QueryOptimizer
17
23
  from .query_mapper import QueryMapper
18
24
  from .query_result_mapper import (
19
25
  QueryResultMapper,
20
26
  )
27
+ from .upsert_mapper import UpsertMapper
21
28
  from .utils import (
22
29
  append_nodes_and_edges,
23
30
  get_query_for_dependencies_pagination,
@@ -32,14 +39,12 @@ class CogniteAdapter:
32
39
  ):
33
40
  self._cognite_client = cognite_client
34
41
 
35
- dm = cognite_client.data_modeling.data_models.retrieve(
36
- ids=data_model_id.as_tuple(),
37
- inline_views=True,
38
- ).latest_version()
39
- view_mapper = ViewMapper(dm.views)
42
+ view_mapper = ViewMapper(cognite_client, data_model_id)
43
+ self._optmizer = QueryOptimizer(cognite_client, data_model_id)
40
44
  self._query_mapper = QueryMapper(view_mapper)
41
45
  self._result_mapper = QueryResultMapper(view_mapper)
42
- self._optmizer = QueryOptimizer(cognite_client)
46
+ self._upsert_mapper = UpsertMapper(view_mapper)
47
+ self._aggregation_mapper = AggregationMapper(view_mapper)
43
48
 
44
49
  def query(
45
50
  self, statement: Statement[TViewInstance], all_pages: bool
@@ -77,6 +82,59 @@ class CogniteAdapter:
77
82
  if not all_pages or last_page:
78
83
  return data, next_cursor_
79
84
 
85
+ def aggregate(
86
+ self, statement: AggregationStatement[TViewInstance]
87
+ ) -> list[AggregationResult]:
88
+ query = self._aggregation_mapper.map(statement)
89
+
90
+ result = self._cognite_client.data_modeling.instances.aggregate(
91
+ view=query.view.as_id(),
92
+ aggregates=query.metric_aggregation,
93
+ filter=query.filters,
94
+ group_by=query.group_by_columns,
95
+ limit=query.limit,
96
+ )
97
+
98
+ return [
99
+ AggregationResult(
100
+ group=item.group,
101
+ value=item.aggregates[0].value,
102
+ aggregate=statement.aggregate_,
103
+ )
104
+ for item in result
105
+ if item.aggregates and item.aggregates[0].value is not None
106
+ ]
107
+
108
+ def upsert(
109
+ self, entries: list[TWritableViewInstance], replace: bool = False
110
+ ) -> None:
111
+ logger = logging.getLogger(__name__)
112
+ operation = self._upsert_mapper.map(entries)
113
+
114
+ for node_chunk in operation.chunk_nodes():
115
+ logger.info(
116
+ f"Upserting {len(node_chunk)} nodes (replace={replace})"
117
+ )
118
+ self._cognite_client.data_modeling.instances.apply(
119
+ nodes=node_chunk,
120
+ replace=replace,
121
+ )
122
+
123
+ for edge_chunk in operation.chunk_edges():
124
+ logger.info(
125
+ f"Upserting {len(edge_chunk)} edges (replace={replace})"
126
+ )
127
+ self._cognite_client.data_modeling.instances.apply(
128
+ edges=edge_chunk,
129
+ replace=replace,
130
+ )
131
+
132
+ for edges_to_remove_chunk in operation.chunk_edges_to_delete():
133
+ logger.info(f"Deleting {len(edges_to_remove_chunk)} edges")
134
+ self._cognite_client.data_modeling.instances.delete(
135
+ edges=[item.as_tuple() for item in edges_to_remove_chunk],
136
+ )
137
+
80
138
  def _query_dependencies_pages(
81
139
  self,
82
140
  cognite_query: CogniteQuery,
@@ -0,0 +1,64 @@
1
+ from dataclasses import dataclass
2
+
3
+ import cognite.client.data_classes.filters as filters
4
+ from cognite.client.data_classes.aggregations import Count, MetricAggregation
5
+ from cognite.client.data_classes.data_modeling import (
6
+ View,
7
+ )
8
+
9
+ from industrial_model.models import TViewInstance
10
+ from industrial_model.statements import AggregationStatement
11
+
12
+ from .filter_mapper import (
13
+ FilterMapper,
14
+ )
15
+ from .view_mapper import ViewMapper
16
+
17
+
18
+ @dataclass
19
+ class AggregationQuery:
20
+ view: View
21
+ metric_aggregation: MetricAggregation
22
+ filters: filters.Filter | None
23
+ group_by_columns: list[str]
24
+ limit: int
25
+
26
+
27
+ class AggregationMapper:
28
+ def __init__(self, view_mapper: ViewMapper):
29
+ self._view_mapper = view_mapper
30
+ self._filter_mapper = FilterMapper(view_mapper)
31
+
32
+ def map(
33
+ self, statement: AggregationStatement[TViewInstance]
34
+ ) -> AggregationQuery:
35
+ root_node = statement.entity.get_view_external_id()
36
+
37
+ root_view = self._view_mapper.get_view(root_node)
38
+
39
+ filters_ = (
40
+ filters.And(
41
+ *self._filter_mapper.map(statement.where_clauses, root_view)
42
+ )
43
+ if statement.where_clauses
44
+ else None
45
+ )
46
+
47
+ metric_aggregation = (
48
+ Count(statement.aggregation_property.property)
49
+ if statement.aggregate_ == "count"
50
+ else None
51
+ )
52
+ if metric_aggregation is None:
53
+ raise ValueError(
54
+ f"Unsupported aggregate function: {statement.aggregate_}"
55
+ )
56
+ return AggregationQuery(
57
+ view=root_view,
58
+ metric_aggregation=metric_aggregation,
59
+ filters=filters_,
60
+ group_by_columns=[
61
+ column.property for column in statement.group_by_columns
62
+ ],
63
+ limit=statement.limit_,
64
+ )
@@ -0,0 +1,35 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from cognite.client.data_classes.data_modeling import (
5
+ EdgeApply,
6
+ NodeApply,
7
+ )
8
+
9
+ from industrial_model.models.entities import EdgeContainer
10
+
11
+ _PAGE_SIZE = 1000
12
+
13
+
14
+ @dataclass
15
+ class UpsertOperation:
16
+ nodes: list[NodeApply]
17
+ edges: list[EdgeApply]
18
+ edges_to_delete: list[EdgeContainer]
19
+
20
+ def chunk_nodes(self) -> list[list[NodeApply]]:
21
+ return self._chunk_list(self.nodes)
22
+
23
+ def chunk_edges(self) -> list[list[EdgeApply]]:
24
+ return self._chunk_list(self.edges)
25
+
26
+ def chunk_edges_to_delete(self) -> list[list[EdgeContainer]]:
27
+ return self._chunk_list(self.edges_to_delete)
28
+
29
+ def _chunk_list(self, entries: list[Any]) -> list[list[Any]]:
30
+ data: list[list[Any]] = []
31
+ for i in range(0, len(entries), _PAGE_SIZE):
32
+ start = i
33
+ end = i + _PAGE_SIZE
34
+ data.append(entries[start:end])
35
+ return data
@@ -1,5 +1,8 @@
1
+ from threading import Lock
2
+
1
3
  from cognite.client import CogniteClient
2
4
 
5
+ from industrial_model.config import DataModelId
3
6
  from industrial_model.models import TViewInstance
4
7
  from industrial_model.statements import (
5
8
  BoolExpression,
@@ -14,11 +17,11 @@ SPACE_PROPERTY = "space"
14
17
 
15
18
  class QueryOptimizer:
16
19
  def __init__(
17
- self,
18
- cognite_client: CogniteClient,
20
+ self, cognite_client: CogniteClient, data_model_id: DataModelId
19
21
  ):
20
- self._all_spaces: list[str] | None = None
22
+ self._all_spaces = data_model_id.instance_spaces
21
23
  self._cognite_client = cognite_client
24
+ self._lock = Lock()
22
25
 
23
26
  def optimize(self, statement: Statement[TViewInstance]) -> None:
24
27
  instance_spaces = statement.entity.view_config.get("instance_spaces")
@@ -58,7 +61,7 @@ class QueryOptimizer:
58
61
  return False
59
62
 
60
63
  def _find_spaces(self, instance_spaces_prefix: str) -> list[str]:
61
- all_spaces = self._get_all_spaces()
64
+ all_spaces = self._load_spaces()
62
65
 
63
66
  return [
64
67
  space
@@ -66,12 +69,18 @@ class QueryOptimizer:
66
69
  if space.startswith(instance_spaces_prefix)
67
70
  ]
68
71
 
69
- def _get_all_spaces(self) -> list[str]:
72
+ def _load_spaces(self) -> list[str]:
70
73
  all_spaces = self._all_spaces
71
- if all_spaces is None:
74
+ if all_spaces:
75
+ return all_spaces
76
+
77
+ with self._lock:
78
+ if self._all_spaces:
79
+ return self._all_spaces
80
+
72
81
  all_spaces = self._cognite_client.data_modeling.spaces.list(
73
82
  limit=-1
74
83
  ).as_ids()
75
84
 
76
- self._all_spaces = all_spaces
77
- return all_spaces
85
+ self._all_spaces = all_spaces
86
+ return all_spaces
@@ -1,5 +1,5 @@
1
1
  from collections import defaultdict
2
- from typing import Any
2
+ from typing import Any, TypedDict
3
3
 
4
4
  from cognite.client.data_classes.data_modeling import (
5
5
  Edge,
@@ -14,10 +14,17 @@ from cognite.client.data_classes.data_modeling.views import (
14
14
  )
15
15
 
16
16
  from industrial_model.constants import EDGE_DIRECTION, EDGE_MARKER, NESTED_SEP
17
+ from industrial_model.models import EdgeContainer
17
18
 
18
19
  from .view_mapper import ViewMapper
19
20
 
20
21
 
22
+ class _PropertyMapping(TypedDict):
23
+ is_list: bool
24
+ nodes: dict[tuple[str, str], list[Node]]
25
+ edges: dict[tuple[str, str], list[Edge]]
26
+
27
+
21
28
  class QueryResultMapper:
22
29
  def __init__(self, view_mapper: ViewMapper):
23
30
  self._view_mapper = view_mapper
@@ -79,8 +86,10 @@ class QueryResultMapper:
79
86
 
80
87
  visited.add(identify)
81
88
  properties = node.properties.get(view_id, {})
89
+
90
+ edges_mapping: dict[str, list[EdgeContainer]] = {}
82
91
  node_id = get_node_id(node)
83
- for mapping_key, (values, is_list) in mappings.items():
92
+ for mapping_key, mapping_value in mappings.items():
84
93
  element = properties.get(mapping_key)
85
94
 
86
95
  element_key: tuple[str, str] = (
@@ -89,15 +98,24 @@ class QueryResultMapper:
89
98
  else (node.space, node.external_id)
90
99
  )
91
100
 
92
- entries = values.get(element_key)
93
- if not entries:
101
+ mapping_nodes = mapping_value.get("nodes", {})
102
+ mapping_edges = mapping_value.get("edges", {})
103
+ is_list = mapping_value.get("is_list", False)
104
+
105
+ node_entries = mapping_nodes.get(element_key)
106
+ if not node_entries:
94
107
  continue
95
108
 
96
- entry_data = self._nodes_to_dict(entries)
109
+ entry_data = self._nodes_to_dict(node_entries)
97
110
  properties[mapping_key] = (
98
111
  entry_data if is_list else entry_data[0]
99
112
  )
100
-
113
+ edge_entries = mapping_edges.get(element_key)
114
+ if edge_entries:
115
+ edges_mapping[mapping_key] = self._edges_to_model(
116
+ edge_entries
117
+ )
118
+ properties["_edges"] = edges_mapping
101
119
  node.properties[view_id] = properties
102
120
 
103
121
  result[node_id].append(node)
@@ -109,19 +127,18 @@ class QueryResultMapper:
109
127
  key: str,
110
128
  view: View,
111
129
  query_result: dict[str, list[Node | Edge]],
112
- ) -> dict[str, tuple[dict[tuple[str, str], list[Node]], bool]]:
113
- mappings: dict[
114
- str, tuple[dict[tuple[str, str], list[Node]], bool]
115
- ] = {}
130
+ ) -> dict[str, _PropertyMapping]:
131
+ mappings: dict[str, _PropertyMapping] = {}
116
132
 
117
133
  for property_name, property in view.properties.items():
118
134
  property_key = f"{key}{NESTED_SEP}{property_name}"
119
135
 
120
- entry: dict[tuple[str, str], list[Node]] | None = None
136
+ nodes: dict[tuple[str, str], list[Node]] | None = None
137
+ edges: dict[tuple[str, str], list[Edge]] | None = None
121
138
  is_list = False
122
139
 
123
140
  if isinstance(property, MappedProperty) and property.source:
124
- entry = self._map_node_property(
141
+ nodes = self._map_node_property(
125
142
  property_key,
126
143
  self._view_mapper.get_view(property.source.external_id),
127
144
  query_result,
@@ -131,7 +148,7 @@ class QueryResultMapper:
131
148
  isinstance(property, SingleReverseDirectRelation)
132
149
  and property.source
133
150
  ):
134
- entry = self._map_node_property(
151
+ nodes = self._map_node_property(
135
152
  property_key,
136
153
  self._view_mapper.get_view(property.source.external_id),
137
154
  query_result,
@@ -142,7 +159,7 @@ class QueryResultMapper:
142
159
  isinstance(property, MultiReverseDirectRelation)
143
160
  and property.source
144
161
  ):
145
- entry = self._map_node_property(
162
+ nodes = self._map_node_property(
146
163
  property_key,
147
164
  self._view_mapper.get_view(property.source.external_id),
148
165
  query_result,
@@ -151,7 +168,7 @@ class QueryResultMapper:
151
168
  is_list = True
152
169
 
153
170
  elif isinstance(property, EdgeConnection) and property.source:
154
- entry = self._map_edge_property(
171
+ nodes, edges = self._map_edge_property(
155
172
  property_key,
156
173
  self._view_mapper.get_view(property.source.external_id),
157
174
  query_result,
@@ -159,8 +176,10 @@ class QueryResultMapper:
159
176
  )
160
177
  is_list = True
161
178
 
162
- if entry:
163
- mappings[property_name] = entry, is_list
179
+ if nodes:
180
+ mappings[property_name] = _PropertyMapping(
181
+ is_list=is_list, nodes=nodes, edges=edges or {}
182
+ )
164
183
 
165
184
  return mappings
166
185
 
@@ -170,17 +189,25 @@ class QueryResultMapper:
170
189
  view: View,
171
190
  query_result: dict[str, list[Node | Edge]],
172
191
  edge_direction: EDGE_DIRECTION,
173
- ) -> dict[tuple[str, str], list[Node]] | None:
192
+ ) -> tuple[
193
+ dict[tuple[str, str], list[Node]] | None,
194
+ dict[tuple[str, str], list[Edge]] | None,
195
+ ]:
174
196
  edge_key = f"{key}{NESTED_SEP}{EDGE_MARKER}"
175
197
  if key not in query_result or edge_key not in query_result:
176
- return None
198
+ return None, None
177
199
 
178
200
  nodes = self._map_node_property(key, view, query_result)
179
201
  if not nodes:
180
- return None
202
+ return None, None
181
203
 
182
204
  visited: set[tuple[str, str]] = set()
183
- result: defaultdict[tuple[str, str], list[Node]] = defaultdict(list)
205
+ nodes_result: defaultdict[tuple[str, str], list[Node]] = defaultdict(
206
+ list
207
+ )
208
+ edges_result: defaultdict[tuple[str, str], list[Edge]] = defaultdict(
209
+ list
210
+ )
184
211
  for edge in query_result[edge_key]:
185
212
  identify = (edge.space, edge.external_id)
186
213
  if not isinstance(edge, Edge) or identify in visited:
@@ -195,15 +222,18 @@ class QueryResultMapper:
195
222
  if edge_direction == "inwards"
196
223
  else (edge.start_node.as_tuple(), edge.end_node.as_tuple())
197
224
  )
198
-
225
+ edges_result[entry_key].append(edge)
199
226
  if node_item := nodes.get(node_key):
200
- result[entry_key].extend(node_item)
227
+ nodes_result[entry_key].extend(node_item)
201
228
 
202
- return dict(result)
229
+ return dict(nodes_result), dict(edges_result)
203
230
 
204
231
  def _nodes_to_dict(self, nodes: list[Node]) -> list[dict[str, Any]]:
205
232
  return [self._node_to_dict(node) for node in nodes]
206
233
 
234
+ def _edges_to_model(self, edges: list[Edge]) -> list[EdgeContainer]:
235
+ return [EdgeContainer.model_validate(edge) for edge in edges]
236
+
207
237
  def _node_to_dict(self, node: Node) -> dict[str, Any]:
208
238
  entry = node.dump()
209
239
  properties: dict[str, dict[str, dict[str, Any]]] = (