cvcdocdb 1.0.0a1__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.
- cvcdocdb/__init__.py +22 -0
- cvcdocdb/base.py +563 -0
- cvcdocdb/drm_entities.py +862 -0
- cvcdocdb/exemples/__init__.py +28 -0
- cvcdocdb/exemples/__main__.py +8 -0
- cvcdocdb/exemples/cli.py +145 -0
- cvcdocdb/exemples/demo_propagation.py +519 -0
- cvcdocdb/exemples/load_ric_o_naf.py +630 -0
- cvcdocdb/exemples/neo4j_got.py +85 -0
- cvcdocdb/exemples/neo4j_movies.py +86 -0
- cvcdocdb/exemples/networkx_bibliografia.py +130 -0
- cvcdocdb/exemples/networkx_karate.py +62 -0
- cvcdocdb/graph_store.py +449 -0
- cvcdocdb/neo4j_graph.py +1891 -0
- cvcdocdb/networkx_graph.py +2622 -0
- cvcdocdb/nx_query.py +143 -0
- cvcdocdb/rdf_schema.py +522 -0
- cvcdocdb/rico_entities.py +8688 -0
- cvcdocdb/schema_gen.py +326 -0
- cvcdocdb-1.0.0a1.dist-info/METADATA +272 -0
- cvcdocdb-1.0.0a1.dist-info/RECORD +24 -0
- cvcdocdb-1.0.0a1.dist-info/WHEEL +5 -0
- cvcdocdb-1.0.0a1.dist-info/licenses/LICENSE +674 -0
- cvcdocdb-1.0.0a1.dist-info/top_level.txt +1 -0
cvcdocdb/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
__version__ = "1.0.0a1"
|
|
2
|
+
|
|
3
|
+
from .base import *
|
|
4
|
+
from .drm_entities import *
|
|
5
|
+
from .graph_store import GraphStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def __getattr__(name):
|
|
9
|
+
"""Lazy import for optional backend modules.
|
|
10
|
+
|
|
11
|
+
Neo4jGraph and NetworkXGraph require optional dependencies (neo4j and
|
|
12
|
+
networkx respectively) that may not be installed in all environments.
|
|
13
|
+
"""
|
|
14
|
+
if name == "Neo4jGraph":
|
|
15
|
+
from .neo4j_graph import Neo4jGraph
|
|
16
|
+
|
|
17
|
+
return Neo4jGraph
|
|
18
|
+
if name == "NetworkXGraph":
|
|
19
|
+
from .networkx_graph import NetworkXGraph
|
|
20
|
+
|
|
21
|
+
return NetworkXGraph
|
|
22
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
cvcdocdb/base.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Core data structures: Node, Relation, WeakNode, WeakRelation.
|
|
2
|
+
|
|
3
|
+
These classes represent the graph primitives used by both Neo4jGraph
|
|
4
|
+
and NetworkXGraph. Nodes carry a primary key (``pk``), a main label,
|
|
5
|
+
optional alternative labels, and optional parent relationships for
|
|
6
|
+
WeakNode hierarchies. Relations connect two nodes with a typed edge.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
# Sentinel per distingir "pk no proporcionat" de "pk=None explícit"
|
|
12
|
+
_UNSET = object()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _single_pk(pk: Dict[str, Union[int, str]], version: int = 5) -> Dict[str, Union[int, str]]:
|
|
16
|
+
"""Merge multi-field PK into a single-field PK for Neo4j 3.x compatibility.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
pk: Primary key dictionary.
|
|
20
|
+
version: Neo4j protocol version (3 or 5).
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A single-field PK dict for version 3, or the original dict for version 5.
|
|
24
|
+
"""
|
|
25
|
+
if version == 3 and len(pk) > 1:
|
|
26
|
+
return {"_".join(pk.keys()): "_".join([str(x) for x in pk.values()])}
|
|
27
|
+
return pk
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _new_key(old_key: str, list_keys: set) -> str:
|
|
31
|
+
"""Generate a unique key by appending _0, _1, ... until a free name is found.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
old_key: The original key to modify.
|
|
35
|
+
list_keys: Set of existing keys to avoid.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A unique key name.
|
|
39
|
+
"""
|
|
40
|
+
n = 0
|
|
41
|
+
while old_key + "_" + str(n) in list_keys:
|
|
42
|
+
n += 1
|
|
43
|
+
return old_key + "_" + str(n)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _mergePK(
|
|
47
|
+
pk_a: Dict[str, Union[int, str]],
|
|
48
|
+
pk_b: Optional[Dict[str, Union[int, str]]],
|
|
49
|
+
version: int = 5,
|
|
50
|
+
) -> Dict[str, Union[int, str]]:
|
|
51
|
+
"""Merge two PK dicts into a composite PK.
|
|
52
|
+
|
|
53
|
+
If pk_b is None, return pk_a as-is (the child inherits the parent's PK).
|
|
54
|
+
"""
|
|
55
|
+
if pk_b is None:
|
|
56
|
+
return pk_a
|
|
57
|
+
k = set(pk_a.keys()).intersection(set(pk_b.keys()))
|
|
58
|
+
|
|
59
|
+
for v in k:
|
|
60
|
+
new_key = _new_key(v, set(pk_a.keys()).union(pk_b.keys()))
|
|
61
|
+
pk_b[new_key] = pk_b.pop(v)
|
|
62
|
+
|
|
63
|
+
pk = {**pk_a, **pk_b}
|
|
64
|
+
|
|
65
|
+
return _single_pk(pk, version)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _setNodePK(value: Dict[str, Any]) -> Dict[str, Any]:
|
|
69
|
+
"""Extract main_label and pk from a dict without mutating the original.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
value: dict containing 'main_label' and 'pk' keys.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A new dict with 'main_label' and 'pk'. Returns empty values
|
|
76
|
+
if the keys are missing.
|
|
77
|
+
"""
|
|
78
|
+
main_label = value.get("main_label")
|
|
79
|
+
pk = value.get("pk")
|
|
80
|
+
return {"main_label": main_label, "pk": pk}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Node:
|
|
84
|
+
"""A graph node with a primary key, labels, and optional parent.
|
|
85
|
+
|
|
86
|
+
Nodes are the fundamental building blocks of the DRM graph. Each node
|
|
87
|
+
has a ``main_label`` (the primary Cypher label), optional ``alternative_labels``,
|
|
88
|
+
and a ``pk`` (primary key) that uniquely identifies it within its label.
|
|
89
|
+
|
|
90
|
+
When a ``parent`` is provided the node becomes a **WeakNode**: its primary
|
|
91
|
+
key is merged with the parent's key to form a composite key, and a typed
|
|
92
|
+
edge is created when the node is inserted into a graph store.
|
|
93
|
+
|
|
94
|
+
By default a node **must** have a ``pk`` or a ``neo4j_id`` (or both).
|
|
95
|
+
If the caller passes ``pk=None`` explicitly, the node is created with
|
|
96
|
+
``_primary_key = None`` and the backend is expected to assign a real
|
|
97
|
+
ID later (e.g. Neo4j generates an internal node ID, which is then
|
|
98
|
+
stored as ``_primary_key``). If the backend never assigns one,
|
|
99
|
+
``_primary_key`` remains ``None``.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
pk: Primary key — an int is converted to ``{"id": pk}``, a dict is used
|
|
103
|
+
as-is. Must be provided (or explicitly ``None``) unless
|
|
104
|
+
``neo4j_id`` is given.
|
|
105
|
+
main_label: The primary label used in Cypher queries.
|
|
106
|
+
alternative_labels: Additional labels attached to the node.
|
|
107
|
+
version: Neo4j protocol version (default 5).
|
|
108
|
+
neo4j_id: Internal Neo4j node ID (used when reconstructing a node
|
|
109
|
+
from a database result).
|
|
110
|
+
**kwargs: Arbitrary additional attributes stored on the node.
|
|
111
|
+
Special kwargs: ``parent`` (Node), ``parent_relation`` (str),
|
|
112
|
+
``is_weak`` (bool), ``_propagate`` (bool), ``dependencies``.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
ValueError: If ``pk`` is not provided at all (not even as ``None``)
|
|
116
|
+
and ``neo4j_id`` is also absent.
|
|
117
|
+
TypeError: If ``pk`` is neither an ``int`` nor a ``dict`` (when
|
|
118
|
+
``pk`` is provided but not ``None``).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
pk: Union[Dict[str, Union[int, str]], None, object] = _UNSET,
|
|
124
|
+
main_label: str = "",
|
|
125
|
+
alternative_labels: Optional[Union[str, List[str]]] = None,
|
|
126
|
+
version: int = 5,
|
|
127
|
+
neo4j_id: Optional[int] = None,
|
|
128
|
+
**kwargs: Any,
|
|
129
|
+
):
|
|
130
|
+
# Definim les propietats
|
|
131
|
+
self._neo4j_id = neo4j_id
|
|
132
|
+
self._version = version
|
|
133
|
+
|
|
134
|
+
# Distingim entre "pk no passat" i "pk=None explícit"
|
|
135
|
+
if pk is _UNSET:
|
|
136
|
+
if neo4j_id is not None:
|
|
137
|
+
self._primary_key = {"id": neo4j_id}
|
|
138
|
+
else:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"Node must have either a primary key (pk) or a neo4j_id. "
|
|
141
|
+
"A node without either cannot be inserted or referenced."
|
|
142
|
+
)
|
|
143
|
+
elif pk is None:
|
|
144
|
+
# pk=None explícit: si hi ha neo4j_id, el fem servir com a PK;
|
|
145
|
+
# si no, el backend generarà un ID després (pk queda None).
|
|
146
|
+
if neo4j_id is not None:
|
|
147
|
+
self._primary_key = {"id": neo4j_id}
|
|
148
|
+
else:
|
|
149
|
+
self._primary_key = None
|
|
150
|
+
elif isinstance(pk, int):
|
|
151
|
+
self._primary_key = {"id": pk}
|
|
152
|
+
elif isinstance(pk, Dict):
|
|
153
|
+
self._primary_key = _single_pk(pk, version)
|
|
154
|
+
else:
|
|
155
|
+
raise TypeError(
|
|
156
|
+
f"pk must be an int, dict, or None, got {type(pk).__name__}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self._main_label = main_label # main_label.lower().capitalize()
|
|
160
|
+
|
|
161
|
+
if isinstance(alternative_labels, str):
|
|
162
|
+
self._label = [
|
|
163
|
+
alternative_labels
|
|
164
|
+
] # [alternative_labels.lower().capitalize()]
|
|
165
|
+
else:
|
|
166
|
+
self._label = (
|
|
167
|
+
alternative_labels
|
|
168
|
+
if alternative_labels is None
|
|
169
|
+
else [x for x in alternative_labels] # x.lower().capitalize()
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self._is_weak = kwargs.pop("is_weak", False)
|
|
173
|
+
self._propagate = kwargs.pop("_propagate", False)
|
|
174
|
+
parent_relation = kwargs.pop("parent_relation", None)
|
|
175
|
+
self._parent = kwargs.pop("parent", None)
|
|
176
|
+
if self._parent is not None:
|
|
177
|
+
assert isinstance(self._parent, Node), "parent must be a Node"
|
|
178
|
+
self._is_weak = True
|
|
179
|
+
if self._parent._primary_key is None:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
"WeakNode parent must have a primary key. "
|
|
182
|
+
"Transient nodes (no pk) cannot be parents."
|
|
183
|
+
)
|
|
184
|
+
self._primary_key = _mergePK(self._parent._primary_key, self._primary_key)
|
|
185
|
+
self._parent_relation = (
|
|
186
|
+
parent_relation if parent_relation is not None else "HAS"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
dependencies = kwargs.pop("dependencies", False)
|
|
190
|
+
if dependencies:
|
|
191
|
+
self._dependencies = dependencies
|
|
192
|
+
else:
|
|
193
|
+
self._dependencies = None
|
|
194
|
+
|
|
195
|
+
if kwargs is not None:
|
|
196
|
+
for k in kwargs:
|
|
197
|
+
self.__setattr__(k, kwargs[k])
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Dunder methods
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def __repr__(self):
|
|
204
|
+
main_label = ":" + self._main_label if len(self._main_label) > 0 else ""
|
|
205
|
+
id = " <Id>: " + str(self._neo4j_id) if self._neo4j_id is not None else ""
|
|
206
|
+
mess = "(a" + main_label + id + ")"
|
|
207
|
+
pk_repr = self._primary_key.__repr__() if self._primary_key is not None else "None"
|
|
208
|
+
mess += """, pk:""" + pk_repr
|
|
209
|
+
mess += (
|
|
210
|
+
(", attributes:" + self.attributes[1].__repr__())
|
|
211
|
+
if len(self.attributes[1]) > 0
|
|
212
|
+
else ""
|
|
213
|
+
)
|
|
214
|
+
return mess
|
|
215
|
+
|
|
216
|
+
def __getitem__(self, key: str) -> Any:
|
|
217
|
+
def get_pk():
|
|
218
|
+
if hasattr(self,'_primary_key'):
|
|
219
|
+
return self._primary_key
|
|
220
|
+
if hasattr(self,'_neo4j_id'):
|
|
221
|
+
return { 'neo4j_id': self._neo4j_id}
|
|
222
|
+
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
if key == "pk":
|
|
226
|
+
return {"main_label": self._main_label, "pk": get_pk() }
|
|
227
|
+
|
|
228
|
+
if key == "main_label":
|
|
229
|
+
return self._main_label
|
|
230
|
+
|
|
231
|
+
if key == "labels":
|
|
232
|
+
if self._label is None:
|
|
233
|
+
return [self._main_label]
|
|
234
|
+
else:
|
|
235
|
+
return [self._main_label] + self._label
|
|
236
|
+
|
|
237
|
+
if key == "pk_attributes":
|
|
238
|
+
return get_pk()
|
|
239
|
+
|
|
240
|
+
attr = self.__dict__.copy()
|
|
241
|
+
if key == "attributes":
|
|
242
|
+
attr.pop("_label", None)
|
|
243
|
+
attr.pop("_main_label")
|
|
244
|
+
attr.pop("_neo4j_id", None)
|
|
245
|
+
pk = attr.pop("_primary_key", None)
|
|
246
|
+
|
|
247
|
+
return pk, attr
|
|
248
|
+
|
|
249
|
+
if "_" + key in attr:
|
|
250
|
+
return attr["_" + key]
|
|
251
|
+
else:
|
|
252
|
+
raise Exception(key + " is not a node attribute")
|
|
253
|
+
|
|
254
|
+
def __setitem__(self, key, value):
|
|
255
|
+
if key == "pk":
|
|
256
|
+
aux = _setNodePK(value)
|
|
257
|
+
self._main_label, self._pk = aux["_main_label"], aux["pk"]
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
if key == "main_label":
|
|
261
|
+
self._main_label = value
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
if key == "labels":
|
|
265
|
+
self._label = value
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if key == "pk_attributes":
|
|
269
|
+
if isinstance(value, dict):
|
|
270
|
+
self._primary_key = value
|
|
271
|
+
else:
|
|
272
|
+
raise Exception("dictionary expected")
|
|
273
|
+
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
self.__setattr__(key, value)
|
|
277
|
+
|
|
278
|
+
def __delitem__(self, key: str) -> None:
|
|
279
|
+
del self.__dict__[key]
|
|
280
|
+
|
|
281
|
+
def __contains__(self, key: str) -> bool:
|
|
282
|
+
return key in self.__dict__ or "_" + key in self.__dict__
|
|
283
|
+
|
|
284
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
285
|
+
"""Dict-like access: return the value for *key*, or *default* if missing.
|
|
286
|
+
|
|
287
|
+
This method provides a compatibility shim so that code written
|
|
288
|
+
with ``node.get("key", default)`` works on Node objects even
|
|
289
|
+
though they are not full ``dict`` subclasses.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
key: The attribute key to retrieve.
|
|
293
|
+
default: Value returned when the key is not found.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
The attribute value, or *default* if the key is absent.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
return self[key]
|
|
300
|
+
except Exception:
|
|
301
|
+
return default
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
# Properties
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def version(self) -> int:
|
|
309
|
+
return self._version
|
|
310
|
+
|
|
311
|
+
@version.setter
|
|
312
|
+
def version(self, value: int) -> None:
|
|
313
|
+
self._version = value
|
|
314
|
+
if value == 3 and self._primary_key is not None:
|
|
315
|
+
self._primary_key = _single_pk(self._primary_key, value)
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def attributes(self) -> Tuple[Optional[Dict[str, Union[int, str]]], Dict[str, Any]]:
|
|
319
|
+
"""Return (pk, attributes) tuple for this node.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
A tuple of (primary_key_dict_or_None, attributes_dict).
|
|
323
|
+
"""
|
|
324
|
+
at = self.__dict__.copy()
|
|
325
|
+
pk = at.pop("_primary_key", None)
|
|
326
|
+
for x in list(at.keys()):
|
|
327
|
+
if x[0] == "_":
|
|
328
|
+
at.pop(x, None)
|
|
329
|
+
return pk, at
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def labels(self) -> List[str]:
|
|
333
|
+
"""Return the full list of labels (main + alternative)."""
|
|
334
|
+
if self._label is None:
|
|
335
|
+
return [self._main_label]
|
|
336
|
+
return [self._main_label] + self._label
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def main_label(self) -> str:
|
|
340
|
+
"""Return the primary Cypher label."""
|
|
341
|
+
return self._main_label
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def neo4j_id(self) -> Optional[int]:
|
|
345
|
+
"""Return the internal Neo4j node id, if set."""
|
|
346
|
+
return self._neo4j_id
|
|
347
|
+
|
|
348
|
+
@neo4j_id.setter
|
|
349
|
+
def neo4j_id(self, value: Optional[int]) -> None:
|
|
350
|
+
self._neo4j_id = value
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------------
|
|
353
|
+
# Utility methods
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
def keys(self) -> List[str]:
|
|
357
|
+
"""Return all public attribute names."""
|
|
358
|
+
return [k for k in self.__dict__.keys() if not k.startswith("_")]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# Class Relation denotes the relation of two nodes in the graph database
|
|
362
|
+
class Relation:
|
|
363
|
+
"""A typed edge connecting two nodes in the graph.
|
|
364
|
+
|
|
365
|
+
Relations store the primary keys of their source and destination nodes
|
|
366
|
+
and can carry arbitrary edge properties.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
src: Source node. Its ``pk`` is extracted and stored.
|
|
370
|
+
dst: Destination node. Its ``pk`` is extracted and stored.
|
|
371
|
+
type: Relation type (e.g. "HAS_NOM", "CONNECTS"). Stored uppercase.
|
|
372
|
+
**kwargs: Edge properties stored as attributes.
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
def __init__(self, src: Node, dst: Node, rel_type: str, **kwargs: Any) -> None:
|
|
376
|
+
"""Initialize a Relation between two nodes.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
src: Source node. Its ``pk`` is extracted and stored.
|
|
380
|
+
dst: Destination node. Its ``pk`` is extracted and stored.
|
|
381
|
+
rel_type: Relation type (e.g. "HAS_NOM", "CONNECTS"). Stored uppercase.
|
|
382
|
+
**kwargs: Edge properties stored as attributes.
|
|
383
|
+
"""
|
|
384
|
+
self._type = rel_type.upper()
|
|
385
|
+
self._src = src["pk"]
|
|
386
|
+
self._dst = dst["pk"]
|
|
387
|
+
|
|
388
|
+
if kwargs is not None:
|
|
389
|
+
for k in kwargs:
|
|
390
|
+
self.__setattr__(k, kwargs[k])
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
# Dunder methods
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
def __repr__(self) -> str:
|
|
397
|
+
return (
|
|
398
|
+
"src:" + str(self._src) + ", dst:" + str(self._dst)
|
|
399
|
+
+ " type:" + self._type
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def __getitem__(self, key: str) -> Any:
|
|
403
|
+
if key == "src":
|
|
404
|
+
return {"main_label": self._src["main_label"], "pk": self._src["pk"]}
|
|
405
|
+
|
|
406
|
+
if key == "dst":
|
|
407
|
+
return {"main_label": self._dst["main_label"], "pk": self._dst["pk"]}
|
|
408
|
+
|
|
409
|
+
if key == "type":
|
|
410
|
+
return self._type
|
|
411
|
+
|
|
412
|
+
attr = self.__dict__.copy()
|
|
413
|
+
if key == "attributes":
|
|
414
|
+
# remove private attributes
|
|
415
|
+
attr.pop("_src", None)
|
|
416
|
+
attr.pop("_dst", None)
|
|
417
|
+
attr.pop("_type", None)
|
|
418
|
+
|
|
419
|
+
if attr == {}:
|
|
420
|
+
return None
|
|
421
|
+
else:
|
|
422
|
+
return attr
|
|
423
|
+
|
|
424
|
+
if key in attr:
|
|
425
|
+
return attr[key]
|
|
426
|
+
raise KeyError(key + " is not a relation attribute")
|
|
427
|
+
|
|
428
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
429
|
+
if key == "src":
|
|
430
|
+
self._src = _setNodePK(value)
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
if key == "dst":
|
|
434
|
+
self._dst = _setNodePK(value)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
if key == "type":
|
|
438
|
+
self._type = value
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
self.__setattr__(key, value)
|
|
442
|
+
|
|
443
|
+
# ------------------------------------------------------------------
|
|
444
|
+
# Properties
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def src(self) -> Tuple[str, Optional[Dict[str, Union[int, str]]]]:
|
|
449
|
+
"""Return (main_label, pk) of the source node."""
|
|
450
|
+
return self._src["main_label"], self._src["pk"]
|
|
451
|
+
|
|
452
|
+
@src.setter
|
|
453
|
+
def src(self, value: Dict[str, Any]) -> None:
|
|
454
|
+
self._src = _setNodePK(value)
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def dst(self) -> Tuple[str, Optional[Dict[str, Union[int, str]]]]:
|
|
458
|
+
"""Return (main_label, pk) of the destination node."""
|
|
459
|
+
return self._dst["main_label"], self._dst["pk"]
|
|
460
|
+
|
|
461
|
+
@dst.setter
|
|
462
|
+
def dst(self, value: Dict[str, Any]) -> None:
|
|
463
|
+
self._dst = _setNodePK(value)
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def type(self) -> str:
|
|
467
|
+
"""Return the relation type (uppercase)."""
|
|
468
|
+
return self._type
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def attributes(self) -> Optional[Dict[str, Any]]:
|
|
472
|
+
"""Return edge attributes, or None if empty."""
|
|
473
|
+
attr = {
|
|
474
|
+
k: v for k, v in self.__dict__.items()
|
|
475
|
+
if not k.startswith("_")
|
|
476
|
+
}
|
|
477
|
+
return attr if attr else None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class WeakNode(Node):
|
|
481
|
+
"""A node whose identity is tied to its parent node.
|
|
482
|
+
|
|
483
|
+
WeakNodes form a parent-child hierarchy where the child's primary
|
|
484
|
+
key is **merged** with the parent's key to produce a composite key.
|
|
485
|
+
This models document structures such as *Document → Section → Page*
|
|
486
|
+
where a child cannot exist without its parent.
|
|
487
|
+
|
|
488
|
+
When a WeakNode is inserted into a graph store, a typed edge
|
|
489
|
+
(``WeakRelation``) is automatically created linking the parent to
|
|
490
|
+
the child. This edge carries the ``_propagate=TRUE`` flag, which
|
|
491
|
+
triggers cascade delete: deleting the parent automatically deletes
|
|
492
|
+
all descendants in the hierarchy.
|
|
493
|
+
|
|
494
|
+
Args:
|
|
495
|
+
parent: The parent ``Node``. Must not be None.
|
|
496
|
+
**kwargs: Forwarded to :class:`Node.__init__`. Common kwargs
|
|
497
|
+
include ``pk`` (child's primary key), ``main_label``,
|
|
498
|
+
``alternative_labels``, ``parent_relation`` (default
|
|
499
|
+
``"HAS"``), and ``_propagate``.
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
AssertionError: If ``parent`` is not a ``Node`` instance.
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
506
|
+
"""Initialize a WeakNode tied to a parent node.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
parent: The parent ``Node``. Required — must be passed as a
|
|
510
|
+
kwarg.
|
|
511
|
+
**kwargs: Passed to :class:`Node.__init__` (``pk``,
|
|
512
|
+
``main_label``, ``alternative_labels``,
|
|
513
|
+
``parent_relation``, ``_propagate``, etc.).
|
|
514
|
+
|
|
515
|
+
Raises:
|
|
516
|
+
AssertionError: If ``parent`` is not provided or is not a
|
|
517
|
+
``Node`` instance.
|
|
518
|
+
"""
|
|
519
|
+
parent = kwargs.pop("parent", None)
|
|
520
|
+
if parent is None:
|
|
521
|
+
raise ValueError(
|
|
522
|
+
"WeakNode requires a 'parent' argument. "
|
|
523
|
+
"Pass parent=<Node> to establish the parent-child relationship."
|
|
524
|
+
)
|
|
525
|
+
assert isinstance(parent, Node), "parent must be a Node"
|
|
526
|
+
kwargs.pop("is_weak", None)
|
|
527
|
+
super().__init__(is_weak=True, parent=parent, **kwargs)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
class WeakRelation(Relation):
|
|
531
|
+
"""A typed edge connecting a parent node to its child (WeakNode).
|
|
532
|
+
|
|
533
|
+
WeakRelations are automatically created when a WeakNode is inserted.
|
|
534
|
+
They carry the ``_propagate=TRUE`` flag, which signals to the graph
|
|
535
|
+
store that deleting the parent should cascade to the child node.
|
|
536
|
+
|
|
537
|
+
Args:
|
|
538
|
+
src: Source (parent) node.
|
|
539
|
+
dst: Destination (child / WeakNode).
|
|
540
|
+
rel_type: Relation type (e.g. ``"HAS_PAGE"``, ``"CONTAINS"``).
|
|
541
|
+
**kwargs: Edge properties. The ``propagate`` kwarg controls
|
|
542
|
+
whether the ``_propagate`` flag is set (default ``True``).
|
|
543
|
+
|
|
544
|
+
Attributes:
|
|
545
|
+
_propagate: Always ``True`` by default. Indicates that deleting
|
|
546
|
+
the source node should cascade delete to the destination node.
|
|
547
|
+
"""
|
|
548
|
+
|
|
549
|
+
def __init__(
|
|
550
|
+
self, src: Node, dst: Node, rel_type: str, **kwargs: Any
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Initialize a WeakRelation with cascade propagation.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
src: Source (parent) node.
|
|
556
|
+
dst: Destination (child) node.
|
|
557
|
+
rel_type: Relation type.
|
|
558
|
+
propagate: If True (default), the edge carries the
|
|
559
|
+
``_propagate=TRUE`` flag for cascade delete.
|
|
560
|
+
"""
|
|
561
|
+
super().__init__(
|
|
562
|
+
src, dst, rel_type, _propagate=kwargs.pop("propagate", True), **kwargs
|
|
563
|
+
)
|