NebulaGraph Python Client 5.3.0
Loading...
Searching...
No Matches
model.py
1# Copyright 2025 vesoft-inc
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15from collections.abc import Sequence as ABCSequence
16from dataclasses import dataclass
17from datetime import date
18from types import UnionType
19from typing import Iterable
20
21from pydantic import (
22 AfterValidator,
23 BaseModel,
24 BeforeValidator,
25 ConfigDict,
26 Field,
27 PlainSerializer,
28 TypeAdapter,
29)
30from pydantic.fields import FieldInfo
31from typing_extensions import (
32 Annotated,
33 Any,
34 ClassVar,
35 Dict,
36 List,
37 Literal,
38 Mapping,
39 Optional,
40 Protocol,
41 Self,
42 Sequence,
43 Type,
44 TypeVar,
45 Union,
46 get_args,
47 get_origin,
48)
49
50from nebulagraph_python.client.base_executor import unwrap_props
52 BasicTargetType,
53 EdgePrimitive,
54 NodePrimitive,
55 NVector,
56)
58 EdgeType,
59 GraphType,
60 NodeType,
61 PropTypeRow,
62)
63
64T = TypeVar("T")
65
66
67class NodeModel(BaseModel):
68 model_config = ConfigDict(revalidate_instances="always")
69
70 def get_element_id(self) -> Optional[int]: ...
71
72 @classmethod
73 def get_type(cls) -> str: ...
74
75 @classmethod
76 def get_labels(cls) -> Sequence[str]: ...
77
78 def get_properties(self) -> Mapping[str, Any]:
79 return get_props(self)
80
81 def get_prop(self, key: str) -> tuple[str, BasicTargetType]:
82 return get_prop(self, key)
83
84 @classmethod
85 def get_primary_keys(cls) -> List[str]: ...
86
87 def get_primary_kv(self) -> Dict[str, BasicTargetType]:
88 return dict(self.get_prop(k) for k in self.get_primary_keys())
89
90 @classmethod
91 def from_primitive(cls, data: NodePrimitive) -> Self: ...
92
93 @classmethod
94 def to_type(cls) -> NodeType: ...
95
96
97class EdgeModel(BaseModel):
98 model_config = ConfigDict(revalidate_instances="always")
99
100 def get_src_element_id(self) -> Optional[int]: ...
101
102 def get_dst_element_id(self) -> Optional[int]: ...
103
104 def get_rank(self) -> Optional[int]:
105 """rank == 0 means not given by input,
106
107 rank == None means no multiedge key specified by the edge type"""
108 ...
109
110 @classmethod
111 def get_type(cls) -> str: ...
112
113 @classmethod
114 def get_src_type(cls) -> str: ...
115
116 @classmethod
117 def get_dst_type(cls) -> str: ...
118
119 @classmethod
120 def get_labels(cls) -> Sequence[str]: ...
121
122 def get_properties(self) -> Mapping[str, Any]:
123 return get_props(self)
124
125 def get_prop(self, key: str) -> tuple[str, BasicTargetType]:
126 return get_prop(self, key)
127
128 @classmethod
129 def get_multiedge_keys(cls) -> List[str]: ...
130
131 def get_multiedge_kv(self) -> Dict[str, BasicTargetType]:
132 return dict(self.get_prop(k) for k in self.get_multiedge_keys())
133
134 @classmethod
135 def from_primitive(cls, data: EdgePrimitive) -> Self: ...
136
137 @classmethod
138 def to_type(cls) -> EdgeType: ...
139
140
141def node_model(
142 type: str,
143 primary_key: Union[str, List[str]],
144 labels: Optional[Sequence[str]] = None,
145) -> Type[NodeModel]:
146 """Dynamically create a Node type based on the given type and labels"""
147 if labels is None:
148 labels = []
149 if isinstance(primary_key, str):
150 primary_key = [primary_key]
151
152 class NodeModelDynamic(NodeModel):
153 # Class variables - For NebulaGraph Type
154 nebula_type__type__: ClassVar[str] = type
155 nebula_type__primary_key__: ClassVar[List[str]] = primary_key
156 nebula_type__labels__: ClassVar[Sequence[str]] = labels
157
158 # Instance variable - For NebulaGraph Object but not properties
159 nebula_obj__element_id__: Optional[int] = Field(default=None, exclude=True)
160
161 @classmethod
162 def from_primitive(cls, data: NodePrimitive) -> Self:
163 return cls.model_validate(
164 {
165 "nebula_obj__element_id__": data["id"],
166 **data["properties"],
167 }
168 )
169
170 def get_element_id(self):
171 return self.nebula_obj__element_id__
172
173 @classmethod
174 def get_type(cls):
175 return cls.nebula_type__type__
176
177 @classmethod
178 def get_labels(cls):
179 return cls.nebula_type__labels__
180
181 @classmethod
182 def get_primary_keys(cls):
183 return cls.nebula_type__primary_key__
184
185 @classmethod
186 def to_type(cls):
187 return node_model_to_type(cls)
188
189 return NodeModelDynamic
190
191
192def edge_model(
193 src_type: str,
194 edge_type: str,
195 dst_type: str,
196 labels: Optional[Sequence[str]] = None,
197 multiedge_keys: Optional[List[str]] = None,
198) -> Type[EdgeModel]:
199 """Dynamically create an Edge type based on the given type information"""
200 if labels is None:
201 labels = []
202 multiedge_keys = multiedge_keys or []
203
204 class EdgeModelDynamic(EdgeModel):
205 # Class variables - For NebulaGraph Type
206 nebula_type__src_type__: ClassVar[str] = src_type
207 nebula_type__edge_type__: ClassVar[str] = edge_type
208 nebula_type__dst_type__: ClassVar[str] = dst_type
209 nebula_type__labels__: ClassVar[Sequence[str]] = labels
210 nebula_type__multiedge_key__: ClassVar[List[str]] = multiedge_keys
211
212 # Instance variable - For NebulaGraph Object but not properties
213 nebula_obj__src_element_id__: Optional[int] = Field(default=None, exclude=True)
214 nebula_obj__dst_element_id__: Optional[int] = Field(default=None, exclude=True)
215 nebula_obj__rank__: Optional[int] = Field(default=None, exclude=True)
216
217 @classmethod
218 def from_primitive(cls, data: EdgePrimitive) -> Self:
219 return cls.model_validate(
220 {
221 "nebula_obj__src_element_id__": data["src_id"],
222 "nebula_obj__dst_element_id__": data["dst_id"],
223 "nebula_obj__rank__": data["rank"],
224 **data["properties"],
225 }
226 )
227
228 def get_src_element_id(self):
229 return self.nebula_obj__src_element_id__
230
231 def get_dst_element_id(self):
232 return self.nebula_obj__dst_element_id__
233
234 def get_rank(self):
235 return self.nebula_obj__rank__
236
237 @classmethod
238 def get_type(cls):
239 return cls.nebula_type__edge_type__
240
241 @classmethod
242 def get_src_type(cls):
243 return cls.nebula_type__src_type__
244
245 @classmethod
246 def get_dst_type(cls):
247 return cls.nebula_type__dst_type__
248
249 @classmethod
250 def get_labels(cls):
251 return cls.nebula_type__labels__
252
253 @classmethod
254 def get_multiedge_keys(cls):
255 return cls.nebula_type__multiedge_key__
256
257 @classmethod
258 def to_type(cls):
259 return edge_model_to_type(cls)
260
261 return EdgeModelDynamic
262
263
264def bind_node(
265 type_desc: Union[
266 GraphType,
267 NodeType,
268 ],
269 name: Optional[str] = None,
270):
271 if isinstance(type_desc, GraphType):
272 if not name:
273 raise ValueError("name is required for GraphType")
274 type_desc = type_desc.nodes[name]
275 elif isinstance(type_desc, NodeType):
276 pass
277 else:
278 raise ValueError(f"Invalid type description: {type_desc}")
279
280 return node_model(
281 type_desc.node_type,
282 type_desc.pr_or_me_keys,
283 type_desc.labels,
284 )
285
286
287def bind_edge(
288 type_desc: Union[
289 GraphType,
290 EdgeType,
291 ],
292 name: Optional[str] = None,
293):
294 if isinstance(type_desc, GraphType):
295 if not name:
296 raise ValueError("name is required for GraphType")
297 type_desc = type_desc.edges[name]
298 elif isinstance(type_desc, EdgeType):
299 pass
300 else:
301 raise ValueError(f"Invalid type description: {type_desc}")
302 return edge_model(
303 type_desc.src_node_type,
304 type_desc.edge_type,
305 type_desc.dst_node_type,
306 type_desc.labels,
307 type_desc.pr_or_me_keys,
308 )
309
310
311def _node_expr(node: NodeModel) -> str:
312 return f"(@`{node.get_type()}` {{ {unwrap_props(node.get_properties())} }})"
313
314
315def _edge_expr(edge: EdgeModel) -> str:
316 return f"[@`{edge.get_type()}` {{ {unwrap_props(edge.get_properties())} }}]"
317
318
319def upsert_batch_nodes_gql(
320 items: Sequence[NodeModel],
321 batch_size: int = 1024,
322) -> Iterable[str]:
323 for i in range(0, len(items), batch_size):
324 batch = items[i : i + batch_size]
325 batch_str = ", ".join(_node_expr(node) for node in batch)
326 yield f"INSERT OR UPDATE {batch_str}"
327
328
329def upsert_gql(
330 node: NodeModel,
331 edge: Optional[EdgeModel] = None,
332 dst_node: Optional[NodeModel] = None,
333):
334 """Upsert a node or a triplet (node, edge, dst_node), and return the GQL string"""
335 if edge is None:
336 return f"""INSERT OR UPDATE {_node_expr(node)}"""
337 else:
338 if dst_node is None:
339 raise ValueError("dst_node is required when edge is provided")
340 return f"""INSERT OR UPDATE {_node_expr(node)}-{_edge_expr(edge)}->{_node_expr(dst_node)}"""
341
342
343def upsert_edge_only_gql(
344 src: Union[int, Dict[str, Any], NodeModel],
345 edge: EdgeModel,
346 dst: Union[int, Dict[str, Any], NodeModel],
347) -> str:
348 """Take in the element_id or the properties filter for the source and destination nodes,
349 and return the GQL string for the edge upsert"""
350
351 def _get_pattern(
352 idt: str, type_: str, element: Union[int, Dict[str, Any], NodeModel]
353 ) -> str:
354 base = f"({idt}@`{type_}`"
355 if isinstance(element, int):
356 return f"{base} WHERE element_id({idt}) = {element} )"
357 elif isinstance(element, NodeModel):
358 return f"{base} {{ {unwrap_props(element.get_primary_kv())} }} )"
359 else:
360 return f"{base} {{ {unwrap_props(element)} }} )"
361
362 src_pattern = _get_pattern("u", edge.get_src_type(), src)
363 dst_pattern = _get_pattern("v", edge.get_dst_type(), dst)
364 return f"""MATCH {src_pattern}, {dst_pattern} INSERT OR UPDATE (u)-{_edge_expr(edge)}->(v)"""
365
366
367def _check_optional(t: Any) -> tuple[bool, type]:
368 """
369 Check if a type annotation is Optional[T], Union[T, None], or T | None.
370 Returns a tuple of (is_optional, inner_type).
371
372 Args:
373 t: A type annotation, typically from a pydantic model_field.annotation
374
375 Returns:
376 A tuple of (is_optional, inner_type) where:
377 - is_optional: True if the type is optional, False otherwise
378 - inner_type: The underlying type T (without the Optional wrapper)
379 """
380 # First, handle the case where t might be None itself
381 if t is None:
382 raise ValueError("t is None")
383
384 # Get origin and args - this works for both Python 3.8+ typing structures
385 origin = get_origin(t)
386 args = get_args(t)
387
388 # Handle both Union, type_extensions.Optional and the PEP 604 | operator
389 if origin is Union or origin is UnionType:
390 if len(args) == 2:
391 if (arg0 := args[0]) is type(None) or (arg1 := args[1]) is type(None):
392 return True, arg0 if arg0 is not type(None) else arg1
393 else:
394 raise ValueError("Union type should have exactly one None type")
395 else:
396 raise ValueError("Union type should have exactly two types")
397
398 # Handle standard types (not optional)
399 return False, t
400
401
402DATA_TYPE_MAPPING = {
403 int: "INT64",
404 float: "FLOAT64",
405 str: "STRING",
406 bool: "BOOL",
407 date: "DATE",
408}
409DATA_TYPE_KEYS = [*DATA_TYPE_MAPPING.keys(), NVector]
410
411
412@dataclass(frozen=True)
414 """Annotation for that in BasicTargetType but not primitive type"""
415
416 name: Literal["AsJSON", "AsNVector"]
417 data: Any
418
419
420class AsJSON(Protocol):
421 def __class_getitem__(cls, T: type):
422 if T is str:
423 raise ValueError("AsJSON does not support str as the inner type")
424
425 return Annotated[
426 T,
427 BeforeValidator(
428 lambda v: (TypeAdapter(T).validate_json(v) if isinstance(v, str) else v)
429 ),
430 PlainSerializer(lambda v: TypeAdapter(T).dump_json(v).decode("utf-8")),
431 AdditionalMetadata(name="AsJSON", data=None),
432 ]
433
434
435class AsNVector(Protocol):
436 """Adapter utilities for Nebula vectors with serialization helpers."""
437
438 @staticmethod
439 def load(value: Sequence[float] | NVector) -> NVector:
440 """Coerce plain sequences into NVectors to simplify model loading."""
441
442 if isinstance(value, NVector):
443 return value
444 if isinstance(value, (str, bytes)):
445 raise TypeError("NVector expects a numeric sequence, got string-like input")
446 if isinstance(value, ABCSequence):
447 return NVector(values=[float(component) for component in value])
448 raise TypeError(f"Cannot coerce value of type {type(value)!r} into NVector")
449
450 @staticmethod
451 def dump(value: NVector) -> list[float]:
452 """Convert an NVector into a plain list for persistence/transport."""
453
454 return list(value.get_values())
455
456 @staticmethod
457 def save(value: Sequence[float] | NVector) -> list[float]:
458 """Coerce the provided value and dump it in one call for convenience."""
459
460 return AsNVector.dump(AsNVector.load(value))
461
462 def __class_getitem__(cls, size: int):
463 def after_validator(v: NVector) -> NVector:
464 if v.dimension != size:
465 raise AssertionError(
466 f"Vector dimension mismatch: got {v.dimension} dim but the type should be dim {size}"
467 )
468 return v
469
470 return Annotated[
471 NVector,
472 BeforeValidator(AsNVector.load),
473 AfterValidator(after_validator),
474 PlainSerializer(AsNVector.dump),
475 AdditionalMetadata(name="AsNVector", data=size),
476 ]
477
478
479def _scan_metadata(t: type, field_info: FieldInfo) -> Optional[AdditionalMetadata]:
480 for meta in [*getattr(t, "__metadata__", []), *field_info.metadata]:
481 if isinstance(meta, AdditionalMetadata):
482 return meta
483 return None
484
485
486def get_data_type(t: type, field_info: FieldInfo) -> str:
487 if t in DATA_TYPE_MAPPING:
488 return DATA_TYPE_MAPPING[t]
489 else:
490 # For list and List
491 if get_origin(t) is list:
492 inner_type = get_args(t)[0]
493 try:
494 inner_type_name = DATA_TYPE_MAPPING[inner_type]
495 except KeyError as e:
496 raise ValueError(
497 f"Unsupported inner type: {inner_type} for List"
498 ) from e
499 return f"LIST<{inner_type_name}>"
500
501 # For AsNVector and AsJSON
502 meta = _scan_metadata(t, field_info)
503 if meta is not None:
504 if meta.name == "AsNVector":
505 return f"VECTOR<{meta.data}, FLOAT>"
506 elif meta.name == "AsJSON":
507 return "STRING"
508 else:
509 raise NotImplementedError(f"Unsupported metadata: {meta.name}")
510 else:
511 raise ValueError(
512 f"No metadata found for the field {field_info}, which is not a primitive type."
513 )
514
515
516def get_prop(obj: NodeModel | EdgeModel, key: str) -> tuple[str, BasicTargetType]:
517 field = obj.__class__.model_fields[key]
518
519 def _get_alias() -> str:
520 if field.alias:
521 return field.alias
522 else:
523 return key
524
525 _, t = _check_optional(field.annotation)
526 meta = _scan_metadata(t, field)
527 # For mannual defined case
528 if meta is not None and meta.name == "AsNVector":
529 v = getattr(obj, key)
530 return (_get_alias(), v)
531
532 # For pydantic handeled case
533 v = obj.model_dump(
534 include={
535 key,
536 }
537 )[key]
538 return (_get_alias(), v)
539
540
541def get_props(obj: NodeModel | EdgeModel) -> Dict[str, BasicTargetType]:
542 return dict(
543 get_prop(obj, k) for k, v in obj.__class__.model_fields.items() if not v.exclude
544 )
545
546
547def _encode_properties(props: Mapping[str, FieldInfo]):
548 res: Dict[str, PropTypeRow] = {}
549 for k, v in props.items():
550 if k.startswith("nebula_obj__"):
551 continue
552 name = v.alias or k
553 is_optional, inner_type = _check_optional(v.annotation)
554 res[name] = PropTypeRow(
555 property_name=name,
556 data_type=get_data_type(inner_type, v),
557 nullable=is_optional,
558 default=None,
559 )
560
561 return res
562
563
564def node_model_to_type(node: Type[NodeModel]) -> NodeType:
565 pk = node.get_primary_keys()
566 props = _encode_properties(node.model_fields)
567 return NodeType(
568 node_type=node.get_type(),
569 labels=list(node.get_labels()),
570 properties=props,
571 pr_or_me_keys=pk,
572 )
573
574
575def edge_model_to_type(edge: Type[EdgeModel]) -> EdgeType:
576 mek = edge.get_multiedge_keys()
577 props = _encode_properties(edge.model_fields)
578 return EdgeType(
579 edge_type=edge.get_type(),
580 src_node_type=edge.get_src_type(),
581 dst_node_type=edge.get_dst_type(),
582 labels=list(edge.get_labels()),
583 properties=props,
584 pr_or_me_keys=mek,
585 )
list[float] save(Sequence[float]|NVector value)
Definition model.py:457
NVector load(Sequence[float]|NVector value)
Definition model.py:439
list[float] dump(NVector value)
Definition model.py:451
tuple[str, BasicTargetType] get_prop(self, str key)
Definition model.py:125
tuple[str, BasicTargetType] get_prop(self, str key)
Definition model.py:81