kitedb 0.2.5__cp313-cp313-win_amd64.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.
kitedb/fluent.py ADDED
@@ -0,0 +1,850 @@
1
+ """
2
+ Ray Database - Fluent API
3
+
4
+ High-level, type-safe API for KiteDB matching the TypeScript fluent style.
5
+
6
+ Example:
7
+ >>> from kitedb import ray, node, edge, prop, optional
8
+ >>>
9
+ >>> # Define schema
10
+ >>> user = node("user",
11
+ ... key=lambda id: f"user:{id}",
12
+ ... props={
13
+ ... "name": prop.string("name"),
14
+ ... "email": prop.string("email"),
15
+ ... "age": optional(prop.int("age")),
16
+ ... }
17
+ ... )
18
+ >>>
19
+ >>> knows = edge("knows", {
20
+ ... "since": prop.int("since"),
21
+ ... })
22
+ >>>
23
+ >>> # Open database
24
+ >>> db = ray("./my-graph", nodes=[user], edges=[knows])
25
+ >>>
26
+ >>> # Insert nodes with fluent API
27
+ >>> alice = db.insert(user).values(
28
+ ... key="alice",
29
+ ... name="Alice",
30
+ ... email="alice@example.com",
31
+ ... age=30
32
+ ... ).returning()
33
+ >>>
34
+ >>> bob = db.insert(user).values(
35
+ ... key="bob",
36
+ ... name="Bob",
37
+ ... email="bob@example.com",
38
+ ... age=25
39
+ ... ).returning()
40
+ >>>
41
+ >>> # Create edges
42
+ >>> db.link(alice, knows, bob, since=2020)
43
+ >>>
44
+ >>> # Traverse graph
45
+ >>> friends = db.from_(alice).out(knows).nodes().to_list()
46
+ >>>
47
+ >>> # Cleanup
48
+ >>> db.close()
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from contextlib import contextmanager
54
+ from dataclasses import dataclass
55
+ from typing import (
56
+ Any,
57
+ Callable,
58
+ Dict,
59
+ Generator,
60
+ Generic,
61
+ Iterator,
62
+ List,
63
+ Optional,
64
+ TypeVar,
65
+ Union,
66
+ overload,
67
+ )
68
+
69
+ from kitedb._kitedb import Database, OpenOptions
70
+
71
+ from .builders import (
72
+ DeleteBuilder,
73
+ InsertBuilder,
74
+ NodeRef,
75
+ UpdateBuilder,
76
+ UpdateByRefBuilder,
77
+ UpdateEdgeBuilder,
78
+ create_link,
79
+ delete_link,
80
+ from_prop_value,
81
+ )
82
+ from .schema import EdgeDef, NodeDef, PropsSchema
83
+ from .traversal import PathFindingBuilder, TraversalBuilder, WeightSpec
84
+
85
+
86
+ N = TypeVar("N", bound=NodeDef)
87
+ E = TypeVar("E", bound=EdgeDef)
88
+
89
+
90
+ @dataclass
91
+ class EdgeData:
92
+ """Edge data with source/destination refs and properties."""
93
+ src: NodeRef[Any]
94
+ dst: NodeRef[Any]
95
+ edge: Any
96
+ props: Dict[str, Any]
97
+
98
+
99
+ class Ray:
100
+ """
101
+ Ray Database - High-level fluent API.
102
+
103
+ Provides a type-safe, chainable interface for graph database operations
104
+ similar to the TypeScript API.
105
+
106
+ Example:
107
+ >>> db = ray("./my-graph", nodes=[user, company], edges=[knows, worksAt])
108
+ >>>
109
+ >>> # Insert
110
+ >>> alice = db.insert(user).values(key="alice", name="Alice").returning()
111
+ >>>
112
+ >>> # Update
113
+ >>> db.update(user).set(email="new@example.com").where(key="user:alice").execute()
114
+ >>>
115
+ >>> # Or update by reference
116
+ >>> db.update(alice).set(age=31).execute()
117
+ >>>
118
+ >>> # Link
119
+ >>> db.link(alice, knows, bob, since=2020)
120
+ >>>
121
+ >>> # Traverse
122
+ >>> friends = db.from_(alice).out(knows).nodes().to_list()
123
+ >>>
124
+ >>> # Close
125
+ >>> db.close()
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ path: str,
131
+ *,
132
+ nodes: List[NodeDef[Any]],
133
+ edges: List[EdgeDef],
134
+ options: Optional[OpenOptions] = None,
135
+ ):
136
+ """
137
+ Open or create a Ray database.
138
+
139
+ Args:
140
+ path: Path to the database file
141
+ nodes: List of node definitions
142
+ edges: List of edge definitions
143
+ options: Optional database options
144
+ """
145
+ self._db = Database(path, options)
146
+ self._nodes: Dict[str, NodeDef[Any]] = {n.name: n for n in nodes}
147
+ self._edges: Dict[str, EdgeDef] = {e.name: e for e in edges}
148
+ self._etype_ids: Dict[EdgeDef, int] = {}
149
+ self._prop_key_ids: Dict[str, int] = {}
150
+
151
+ # Build key prefix -> NodeDef cache for fast lookups
152
+ self._key_prefix_to_node_def: Dict[str, NodeDef[Any]] = {}
153
+ for node_def in nodes:
154
+ try:
155
+ test_key = node_def.key_fn("__test__")
156
+ prefix = test_key.replace("__test__", "")
157
+ self._key_prefix_to_node_def[prefix] = node_def
158
+ except Exception:
159
+ pass
160
+
161
+ # Initialize schema
162
+ self._init_schema(nodes, edges)
163
+
164
+ def _init_schema(
165
+ self,
166
+ nodes: List[NodeDef[Any]],
167
+ edges: List[EdgeDef],
168
+ ) -> None:
169
+ """Initialize edge types and property keys."""
170
+ self._db.begin()
171
+ try:
172
+ # Define edge types
173
+ for edge in edges:
174
+ etype_id = self._db.get_or_create_etype(edge.name)
175
+ self._etype_ids[edge] = etype_id
176
+ edge._etype_id = etype_id
177
+
178
+ # Define property keys for nodes
179
+ for node in nodes:
180
+ node._prop_key_ids = {}
181
+ for prop_name, prop_def in node.props.items():
182
+ key = f"{node.name}:{prop_def.name}"
183
+ if key not in self._prop_key_ids:
184
+ prop_key_id = self._db.get_or_create_propkey(prop_def.name)
185
+ self._prop_key_ids[key] = prop_key_id
186
+ node._prop_key_ids[prop_name] = self._prop_key_ids[key]
187
+
188
+ # Define property keys for edges
189
+ for edge in edges:
190
+ edge._prop_key_ids = {}
191
+ for prop_name, prop_def in edge.props.items():
192
+ key = f"{edge.name}:{prop_def.name}"
193
+ if key not in self._prop_key_ids:
194
+ prop_key_id = self._db.get_or_create_propkey(prop_def.name)
195
+ self._prop_key_ids[key] = prop_key_id
196
+ edge._prop_key_ids[prop_name] = self._prop_key_ids[key]
197
+
198
+ self._db.commit()
199
+ except Exception:
200
+ self._db.rollback()
201
+ raise
202
+
203
+ # ==========================================================================
204
+ # Schema Resolution Helpers
205
+ # ==========================================================================
206
+
207
+ def _resolve_etype_id(self, edge_def: EdgeDef) -> int:
208
+ """Resolve edge type ID from definition."""
209
+ etype_id = self._etype_ids.get(edge_def)
210
+ if etype_id is None:
211
+ raise ValueError(f"Unknown edge type: {edge_def.name}")
212
+ return etype_id
213
+
214
+ def _resolve_prop_key_id(
215
+ self,
216
+ def_: Union[NodeDef[Any], EdgeDef],
217
+ prop_name: str,
218
+ ) -> int:
219
+ """Resolve property key ID from definition."""
220
+ prop_key_id = def_._prop_key_ids.get(prop_name)
221
+ if prop_key_id is None:
222
+ raise ValueError(f"Unknown property: {prop_name} on {def_.name}")
223
+ return prop_key_id
224
+
225
+ def _get_node_def(self, node_id: int) -> Optional[NodeDef[Any]]:
226
+ """Get node definition from node ID by matching key prefix."""
227
+ key = self._db.get_node_key(node_id)
228
+ if key:
229
+ for prefix, node_def in self._key_prefix_to_node_def.items():
230
+ if key.startswith(prefix):
231
+ return node_def
232
+
233
+ # Fall back to first node def
234
+ if self._nodes:
235
+ return next(iter(self._nodes.values()))
236
+ return None
237
+
238
+ def _load_node_props(self, node_id: int, node_def: NodeDef[Any]) -> Dict[str, Any]:
239
+ """Load all properties for a node using single FFI call."""
240
+ props: Dict[str, Any] = {}
241
+ # Use get_node_props() for single FFI call instead of per-property calls
242
+ all_props = self._db.get_node_props(node_id)
243
+ if all_props is None:
244
+ return props
245
+
246
+ # Build reverse mapping: prop_key_id -> prop_name
247
+ # This is cached on node_def._prop_key_ids
248
+ key_id_to_name = {v: k for k, v in node_def._prop_key_ids.items()}
249
+
250
+ for node_prop in all_props:
251
+ prop_name = key_id_to_name.get(node_prop.key_id)
252
+ if prop_name is not None:
253
+ props[prop_name] = from_prop_value(node_prop.value)
254
+
255
+ return props
256
+
257
+ # ==========================================================================
258
+ # Node Operations
259
+ # ==========================================================================
260
+
261
+ def insert(self, node: NodeDef[Any]) -> InsertBuilder[NodeDef[Any]]:
262
+ """
263
+ Insert a new node.
264
+
265
+ Args:
266
+ node: Node definition
267
+
268
+ Returns:
269
+ InsertBuilder for chaining
270
+
271
+ Example:
272
+ >>> alice = db.insert(user).values(
273
+ ... key="alice",
274
+ ... name="Alice",
275
+ ... email="alice@example.com"
276
+ ... ).returning()
277
+ """
278
+ return InsertBuilder(
279
+ db=self._db,
280
+ node_def=node,
281
+ resolve_prop_key_id=self._resolve_prop_key_id,
282
+ )
283
+
284
+ @overload
285
+ def update(self, node_or_ref: NodeDef[Any]) -> UpdateBuilder[NodeDef[Any]]: ...
286
+
287
+ @overload
288
+ def update(self, node_or_ref: NodeRef[Any]) -> UpdateByRefBuilder: ...
289
+
290
+ def update(
291
+ self,
292
+ node_or_ref: Union[NodeDef[Any], NodeRef[Any]],
293
+ ) -> Union[UpdateBuilder[Any], UpdateByRefBuilder]:
294
+ """
295
+ Update a node by definition or reference.
296
+
297
+ Args:
298
+ node_or_ref: Node definition or node reference
299
+
300
+ Returns:
301
+ UpdateBuilder or UpdateByRefBuilder for chaining
302
+
303
+ Example:
304
+ >>> # By definition with where clause
305
+ >>> db.update(user).set(email="new@example.com").where(key="user:alice").execute()
306
+ >>>
307
+ >>> # By reference
308
+ >>> db.update(alice).set(age=31).execute()
309
+ """
310
+ if isinstance(node_or_ref, NodeRef):
311
+ return UpdateByRefBuilder(
312
+ db=self._db,
313
+ node_ref=node_or_ref,
314
+ resolve_prop_key_id=self._resolve_prop_key_id,
315
+ )
316
+ return UpdateBuilder(
317
+ db=self._db,
318
+ node_def=node_or_ref,
319
+ resolve_prop_key_id=self._resolve_prop_key_id,
320
+ )
321
+
322
+ @overload
323
+ def delete(self, node_or_ref: NodeDef[Any]) -> DeleteBuilder[NodeDef[Any]]: ...
324
+
325
+ @overload
326
+ def delete(self, node_or_ref: NodeRef[Any]) -> bool: ...
327
+
328
+ def delete(
329
+ self,
330
+ node_or_ref: Union[NodeDef[Any], NodeRef[Any]],
331
+ ) -> Union[DeleteBuilder[Any], bool]:
332
+ """
333
+ Delete a node by definition or reference.
334
+
335
+ Args:
336
+ node_or_ref: Node definition or node reference
337
+
338
+ Returns:
339
+ DeleteBuilder for chaining, or bool if deleting by reference
340
+
341
+ Example:
342
+ >>> # By definition with where clause
343
+ >>> db.delete(user).where(key="user:bob").execute()
344
+ >>>
345
+ >>> # By reference (immediate execution)
346
+ >>> db.delete(bob)
347
+ """
348
+ if isinstance(node_or_ref, NodeRef):
349
+ return DeleteBuilder(self._db, node_or_ref.node_def).where(
350
+ id=node_or_ref.id
351
+ ).execute()
352
+ return DeleteBuilder(self._db, node_or_ref)
353
+
354
+ def get(
355
+ self,
356
+ node: NodeDef[Any],
357
+ key: Any,
358
+ ) -> Optional[NodeRef[Any]]:
359
+ """
360
+ Get a node by key.
361
+
362
+ Args:
363
+ node: Node definition
364
+ key: Application key (will be transformed by key function)
365
+
366
+ Returns:
367
+ NodeRef with loaded properties, or None if not found
368
+
369
+ Example:
370
+ >>> alice = db.get(user, "alice")
371
+ >>> if alice:
372
+ ... print(alice.name, alice.email)
373
+ """
374
+ full_key = node.key_fn(key)
375
+ node_id = self._db.get_node_by_key(full_key)
376
+
377
+ if node_id is None:
378
+ return None
379
+
380
+ props = self._load_node_props(node_id, node)
381
+ return NodeRef(id=node_id, key=full_key, node_def=node, props=props)
382
+
383
+ def get_ref(
384
+ self,
385
+ node: NodeDef[Any],
386
+ key: Any,
387
+ ) -> Optional[NodeRef[Any]]:
388
+ """
389
+ Get a lightweight node reference by key (without loading properties).
390
+
391
+ This is faster than get() when you only need the reference for
392
+ traversals or edge operations.
393
+
394
+ Args:
395
+ node: Node definition
396
+ key: Application key
397
+
398
+ Returns:
399
+ NodeRef without properties, or None if not found
400
+ """
401
+ full_key = node.key_fn(key)
402
+ node_id = self._db.get_node_by_key(full_key)
403
+
404
+ if node_id is None:
405
+ return None
406
+
407
+ return NodeRef(id=node_id, key=full_key, node_def=node, props={})
408
+
409
+ def exists(self, node_ref: NodeRef[Any]) -> bool:
410
+ """Check if a node exists."""
411
+ return self._db.node_exists(node_ref.id)
412
+
413
+ # ==========================================================================
414
+ # Edge Operations
415
+ # ==========================================================================
416
+
417
+ def link(
418
+ self,
419
+ src: NodeRef[Any],
420
+ edge: EdgeDef,
421
+ dst: NodeRef[Any],
422
+ props: Optional[Dict[str, Any]] = None,
423
+ **kwargs: Any,
424
+ ) -> None:
425
+ """
426
+ Create an edge between two nodes.
427
+
428
+ Args:
429
+ src: Source node reference
430
+ edge: Edge definition
431
+ dst: Destination node reference
432
+ props: Optional edge properties as dict
433
+ **kwargs: Optional edge properties as keyword arguments
434
+
435
+ Example:
436
+ >>> db.link(alice, knows, bob, since=2020)
437
+ >>> # or
438
+ >>> db.link(alice, knows, bob, {"since": 2020})
439
+ """
440
+ all_props = {**(props or {}), **kwargs}
441
+ create_link(
442
+ db=self._db,
443
+ src=src,
444
+ edge_def=edge,
445
+ dst=dst,
446
+ props=all_props if all_props else None,
447
+ resolve_etype_id=self._resolve_etype_id,
448
+ resolve_prop_key_id=self._resolve_prop_key_id,
449
+ )
450
+
451
+ def unlink(
452
+ self,
453
+ src: NodeRef[Any],
454
+ edge: EdgeDef,
455
+ dst: NodeRef[Any],
456
+ ) -> None:
457
+ """
458
+ Remove an edge between two nodes.
459
+
460
+ Args:
461
+ src: Source node reference
462
+ edge: Edge definition
463
+ dst: Destination node reference
464
+
465
+ Example:
466
+ >>> db.unlink(alice, knows, bob)
467
+ """
468
+ delete_link(
469
+ db=self._db,
470
+ src=src,
471
+ edge_def=edge,
472
+ dst=dst,
473
+ resolve_etype_id=self._resolve_etype_id,
474
+ )
475
+
476
+ def has_edge(
477
+ self,
478
+ src: NodeRef[Any],
479
+ edge: EdgeDef,
480
+ dst: NodeRef[Any],
481
+ ) -> bool:
482
+ """
483
+ Check if an edge exists between two nodes.
484
+
485
+ Args:
486
+ src: Source node reference
487
+ edge: Edge definition
488
+ dst: Destination node reference
489
+
490
+ Returns:
491
+ True if the edge exists
492
+ """
493
+ etype_id = self._resolve_etype_id(edge)
494
+ return self._db.edge_exists(src.id, etype_id, dst.id)
495
+
496
+ def update_edge(
497
+ self,
498
+ src: NodeRef[Any],
499
+ edge: EdgeDef,
500
+ dst: NodeRef[Any],
501
+ ) -> UpdateEdgeBuilder[EdgeDef]:
502
+ """
503
+ Update edge properties.
504
+
505
+ Args:
506
+ src: Source node reference
507
+ edge: Edge definition
508
+ dst: Destination node reference
509
+
510
+ Returns:
511
+ UpdateEdgeBuilder for chaining
512
+
513
+ Example:
514
+ >>> db.update_edge(alice, knows, bob).set(weight=0.9).execute()
515
+ """
516
+ return UpdateEdgeBuilder(
517
+ db=self._db,
518
+ src=src,
519
+ edge_def=edge,
520
+ dst=dst,
521
+ resolve_etype_id=self._resolve_etype_id,
522
+ resolve_prop_key_id=self._resolve_prop_key_id,
523
+ )
524
+
525
+ # ==========================================================================
526
+ # Traversal
527
+ # ==========================================================================
528
+
529
+ def from_(self, node: NodeRef[Any]) -> TraversalBuilder[Any]:
530
+ """
531
+ Start a traversal from a node.
532
+
533
+ Note: Named `from_` because `from` is a Python reserved word.
534
+
535
+ Args:
536
+ node: Starting node reference
537
+
538
+ Returns:
539
+ TraversalBuilder for chaining
540
+
541
+ Example:
542
+ >>> friends = db.from_(alice).out(knows).nodes().to_list()
543
+ >>>
544
+ >>> young_friends = (
545
+ ... db.from_(alice)
546
+ ... .out(knows)
547
+ ... .where_node(lambda n: n.age < 35)
548
+ ... .nodes()
549
+ ... .to_list()
550
+ ... )
551
+ """
552
+ return TraversalBuilder(
553
+ db=self._db,
554
+ start_nodes=[node],
555
+ resolve_etype_id=self._resolve_etype_id,
556
+ resolve_prop_key_id=self._resolve_prop_key_id,
557
+ get_node_def=self._get_node_def,
558
+ )
559
+
560
+ def shortest_path(
561
+ self,
562
+ source: NodeRef[Any],
563
+ weight: Optional[WeightSpec] = None,
564
+ ) -> PathFindingBuilder[Any]:
565
+ """
566
+ Start a pathfinding query from a node.
567
+
568
+ Args:
569
+ source: Starting node reference
570
+
571
+ Returns:
572
+ PathFindingBuilder for chaining
573
+
574
+ Example:
575
+ >>> path = db.shortest_path(alice).to(bob).find()
576
+ >>> if path:
577
+ ... for node in path.nodes:
578
+ ... print(node.key)
579
+ """
580
+ builder = PathFindingBuilder(
581
+ db=self._db,
582
+ source=source,
583
+ resolve_etype_id=self._resolve_etype_id,
584
+ resolve_prop_key_id=self._resolve_prop_key_id,
585
+ get_node_def=self._get_node_def,
586
+ )
587
+ if weight is not None:
588
+ builder.weight(weight)
589
+ return builder
590
+
591
+ # ==========================================================================
592
+ # Listing and Counting
593
+ # ==========================================================================
594
+
595
+ def all(self, node_def: NodeDef[Any]) -> Iterator[NodeRef[Any]]:
596
+ """
597
+ Iterate all nodes of a specific type.
598
+
599
+ Args:
600
+ node_def: Node definition to filter by
601
+
602
+ Yields:
603
+ NodeRef objects with properties
604
+
605
+ Example:
606
+ >>> for user in db.all(user):
607
+ ... print(user.name)
608
+ """
609
+ # Get key prefix for filtering using Rust prefix-based listing
610
+ try:
611
+ test_key = node_def.key_fn("__test__")
612
+ key_prefix = test_key.replace("__test__", "")
613
+ except Exception:
614
+ key_prefix = ""
615
+
616
+ # Use Rust prefix-based filtering
617
+ for node_id in self._db.list_nodes_with_prefix(key_prefix):
618
+ key = self._db.get_node_key(node_id)
619
+ if key:
620
+ props = self._load_node_props(node_id, node_def)
621
+ yield NodeRef(id=node_id, key=key, node_def=node_def, props=props)
622
+
623
+ def count(self, node_def: Optional[NodeDef[Any]] = None) -> int:
624
+ """
625
+ Count nodes, optionally filtered by type.
626
+
627
+ Args:
628
+ node_def: Optional node definition to filter by
629
+
630
+ Returns:
631
+ Number of matching nodes
632
+ """
633
+ if node_def is None:
634
+ return self._db.count_nodes()
635
+
636
+ # Filter by type using Rust prefix-based count
637
+ try:
638
+ test_key = node_def.key_fn("__test__")
639
+ key_prefix = test_key.replace("__test__", "")
640
+ except Exception:
641
+ return 0
642
+
643
+ return self._db.count_nodes_with_prefix(key_prefix)
644
+
645
+ def count_edges(self, edge_def: Optional[EdgeDef] = None) -> int:
646
+ """
647
+ Count edges, optionally filtered by type.
648
+
649
+ Args:
650
+ edge_def: Optional edge definition to filter by
651
+
652
+ Returns:
653
+ Number of matching edges
654
+ """
655
+ if edge_def is None:
656
+ return self._db.count_edges()
657
+
658
+ etype_id = self._resolve_etype_id(edge_def)
659
+ return self._db.count_edges_by_type(etype_id)
660
+
661
+ def all_edges(self, edge_def: Optional[EdgeDef] = None) -> Iterator[EdgeData]:
662
+ """
663
+ Iterate all edges, optionally filtered by type.
664
+
665
+ Yields:
666
+ EdgeData objects with src/dst refs and edge properties
667
+ """
668
+ etype_id: Optional[int] = None
669
+ if edge_def is not None:
670
+ etype_id = self._resolve_etype_id(edge_def)
671
+
672
+ edges = self._db.list_edges(etype_id)
673
+ for edge in edges:
674
+ src_def = self._get_node_def(edge.src)
675
+ dst_def = self._get_node_def(edge.dst)
676
+
677
+ src_key = self._db.get_node_key(edge.src) or f"node:{edge.src}"
678
+ dst_key = self._db.get_node_key(edge.dst) or f"node:{edge.dst}"
679
+
680
+ if src_def is None or dst_def is None:
681
+ continue
682
+
683
+ src_ref = NodeRef(id=edge.src, key=src_key, node_def=src_def, props={})
684
+ dst_ref = NodeRef(id=edge.dst, key=dst_key, node_def=dst_def, props={})
685
+
686
+ props: Dict[str, Any] = {}
687
+ if edge_def is not None and edge_def.props:
688
+ for prop_name in edge_def.props.keys():
689
+ prop_key_id = self._resolve_prop_key_id(edge_def, prop_name)
690
+ prop_value = self._db.get_edge_prop(edge.src, edge.etype, edge.dst, prop_key_id)
691
+ if prop_value is not None:
692
+ props[prop_name] = from_prop_value(prop_value)
693
+
694
+ yield EdgeData(src=src_ref, dst=dst_ref, edge=edge, props=props)
695
+
696
+ # ==========================================================================
697
+ # Database Operations
698
+ # ==========================================================================
699
+
700
+ def stats(self) -> Any:
701
+ """Get database statistics."""
702
+ return self._db.stats()
703
+
704
+ def check(self) -> Any:
705
+ """Check database integrity."""
706
+ result = self._db.check()
707
+ for edge_name, edge_def in self._edges.items():
708
+ if getattr(edge_def, "_etype_id", None) is None:
709
+ result.warnings.append(
710
+ f"Edge type '{edge_name}' has no assigned etype_id"
711
+ )
712
+ return result
713
+
714
+ def optimize(self) -> None:
715
+ """Optimize the database."""
716
+ self._db.optimize()
717
+
718
+ def close(self) -> None:
719
+ """Close the database."""
720
+ self._db.close()
721
+
722
+ @property
723
+ def raw(self) -> Database:
724
+ """Get the raw database handle (escape hatch)."""
725
+ return self._db
726
+
727
+ # ==========================================================================
728
+ # Transaction Batching
729
+ # ==========================================================================
730
+
731
+ def batch(self, operations: List[Any]) -> List[Any]:
732
+ """
733
+ Execute multiple operations in a single transaction.
734
+
735
+ Each item can be a callable or an executor with .execute()/.returning().
736
+ """
737
+ self._db.begin()
738
+ try:
739
+ results: List[Any] = []
740
+ for op in operations:
741
+ if callable(op):
742
+ results.append(op())
743
+ elif hasattr(op, "returning"):
744
+ results.append(op.returning())
745
+ elif hasattr(op, "execute"):
746
+ results.append(op.execute())
747
+ else:
748
+ raise ValueError("Unsupported batch operation")
749
+ self._db.commit()
750
+ return results
751
+ except Exception:
752
+ self._db.rollback()
753
+ raise
754
+
755
+ @contextmanager
756
+ def transaction(self) -> Generator[Ray, None, None]:
757
+ """
758
+ Context manager for batching multiple operations in a single transaction.
759
+
760
+ This is more efficient than letting each operation auto-commit.
761
+
762
+ Example:
763
+ >>> with db.transaction():
764
+ ... alice = db.insert(user).values(key="alice", name="Alice").returning()
765
+ ... bob = db.insert(user).values(key="bob", name="Bob").returning()
766
+ ... db.link(alice, knows, bob, since=2024)
767
+ ... # All operations commit together on exit
768
+
769
+ Note:
770
+ If an exception occurs, the transaction is rolled back.
771
+ """
772
+ self._db.begin()
773
+ try:
774
+ yield self
775
+ self._db.commit()
776
+ except Exception:
777
+ self._db.rollback()
778
+ raise
779
+
780
+ def in_transaction(self) -> bool:
781
+ """Check if currently in a transaction."""
782
+ return self._db.has_transaction()
783
+
784
+ # ==========================================================================
785
+ # Context Manager
786
+ # ==========================================================================
787
+
788
+ def __enter__(self) -> Ray:
789
+ return self
790
+
791
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> bool:
792
+ self.close()
793
+ return False
794
+
795
+
796
+ # ============================================================================
797
+ # Entry Point
798
+ # ============================================================================
799
+
800
+ def ray(
801
+ path: str,
802
+ *,
803
+ nodes: List[NodeDef[Any]],
804
+ edges: List[EdgeDef],
805
+ options: Optional[OpenOptions] = None,
806
+ ) -> Ray:
807
+ """
808
+ Open or create a Ray database.
809
+
810
+ This is the main entry point for the fluent API.
811
+
812
+ Args:
813
+ path: Path to the database file
814
+ nodes: List of node definitions
815
+ edges: List of edge definitions
816
+ options: Optional database options
817
+
818
+ Returns:
819
+ Ray database instance
820
+
821
+ Example:
822
+ >>> from kitedb import ray, node, edge, prop, optional
823
+ >>>
824
+ >>> user = node("user",
825
+ ... key=lambda id: f"user:{id}",
826
+ ... props={
827
+ ... "name": prop.string("name"),
828
+ ... "email": prop.string("email"),
829
+ ... "age": optional(prop.int("age")),
830
+ ... }
831
+ ... )
832
+ >>>
833
+ >>> knows = edge("knows", {
834
+ ... "since": prop.int("since"),
835
+ ... })
836
+ >>>
837
+ >>> db = ray("./my-graph", nodes=[user], edges=[knows])
838
+ >>>
839
+ >>> # Use as context manager
840
+ >>> with ray("./my-graph", nodes=[user], edges=[knows]) as db:
841
+ ... alice = db.insert(user).values(key="alice", name="Alice").returning()
842
+ """
843
+ return Ray(path, nodes=nodes, edges=edges, options=options)
844
+
845
+
846
+ __all__ = [
847
+ "Ray",
848
+ "ray",
849
+ "EdgeData",
850
+ ]