tapestry-orm 0.0.1__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.
- tapestry/__init__.py +11 -0
- tapestry/base.py +401 -0
- tapestry/base.pyi +54 -0
- tapestry/edge.py +271 -0
- tapestry/edge.pyi +45 -0
- tapestry/engine.py +301 -0
- tapestry/field.py +315 -0
- tapestry/field.pyi +113 -0
- tapestry/node.py +169 -0
- tapestry/node.pyi +28 -0
- tapestry/py.typed +0 -0
- tapestry/query.py +404 -0
- tapestry/query.pyi +33 -0
- tapestry/table.py +464 -0
- tapestry/tokenizer.py +156 -0
- tapestry/utils.py +70 -0
- tapestry_orm-0.0.1.dist-info/METADATA +418 -0
- tapestry_orm-0.0.1.dist-info/RECORD +19 -0
- tapestry_orm-0.0.1.dist-info/WHEEL +4 -0
tapestry/field.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Optional, Union, Type, TypeVar, Generic, TYPE_CHECKING
|
|
5
|
+
from dataclasses import dataclass, field as dataclass_field
|
|
6
|
+
from pydantic.fields import FieldInfo, ComputedFieldInfo
|
|
7
|
+
from surrealdb import RecordID
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Import at module level to avoid circular imports
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .node import Node
|
|
13
|
+
from .edge import Edge
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
T = TypeVar('T')
|
|
17
|
+
|
|
18
|
+
class Field(Generic[T]):
|
|
19
|
+
"""Represents a field in a SurrealDB model with type-safe comparisons."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, name: str, model_class: str, field_type: Type[T]):
|
|
22
|
+
self.name = name
|
|
23
|
+
self.model_class = model_class
|
|
24
|
+
self.field_type = field_type
|
|
25
|
+
|
|
26
|
+
def __repr__(self):
|
|
27
|
+
return f"{self.model_class}.{self.name}"
|
|
28
|
+
|
|
29
|
+
# Comparison operators with proper type hints
|
|
30
|
+
def __eq__(self, other: T) -> Condition: # type: ignore[override]
|
|
31
|
+
return Condition(self, "==", other)
|
|
32
|
+
|
|
33
|
+
def __ne__(self, other: T) -> Condition: # type: ignore[override]
|
|
34
|
+
return Condition(self, "!=", other)
|
|
35
|
+
|
|
36
|
+
def __lt__(self, other: T) -> Condition:
|
|
37
|
+
return Condition(self, "<", other)
|
|
38
|
+
|
|
39
|
+
def __le__(self, other: T) -> Condition:
|
|
40
|
+
return Condition(self, "<=", other)
|
|
41
|
+
|
|
42
|
+
def __gt__(self, other: T) -> Condition:
|
|
43
|
+
return Condition(self, ">", other)
|
|
44
|
+
|
|
45
|
+
def __ge__(self, other: T) -> Condition:
|
|
46
|
+
return Condition(self, ">=", other)
|
|
47
|
+
|
|
48
|
+
def __matmul__(self, other: T) -> Condition:
|
|
49
|
+
"""Fulltext search operator using @ symbol."""
|
|
50
|
+
return Condition(self, "@", other)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ComputedFieldDescriptor(Field[T]):
|
|
54
|
+
"""Descriptor for computed fields that delegates to Pydantic's property."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, name: str, model_class: str, field_type: Type[T], original_descriptor: Optional[property] = None):
|
|
57
|
+
super().__init__(name, model_class, field_type)
|
|
58
|
+
self.original_descriptor = original_descriptor
|
|
59
|
+
|
|
60
|
+
def __get__(self, instance, owner):
|
|
61
|
+
if instance is None:
|
|
62
|
+
# When accessed on the class, return the Field for query building
|
|
63
|
+
return self
|
|
64
|
+
# When accessed on an instance, delegate to Pydantic's descriptor
|
|
65
|
+
if self.original_descriptor:
|
|
66
|
+
return self.original_descriptor.__get__(instance, owner)
|
|
67
|
+
raise AttributeError(f"No descriptor for {self.name}")
|
|
68
|
+
|
|
69
|
+
def __set__(self, instance, value):
|
|
70
|
+
if hasattr(self.original_descriptor, '__set__'):
|
|
71
|
+
return self.original_descriptor.__set__(instance, value)
|
|
72
|
+
raise AttributeError(f"can't set attribute {self.name}")
|
|
73
|
+
|
|
74
|
+
def __delete__(self, instance):
|
|
75
|
+
if hasattr(self.original_descriptor, '__delete__'):
|
|
76
|
+
return self.original_descriptor.__delete__(instance)
|
|
77
|
+
raise AttributeError(f"can't delete attribute {self.name}")
|
|
78
|
+
|
|
79
|
+
def __matmul__(self, other: T) -> Condition:
|
|
80
|
+
"""Fulltext search operator using @ symbol."""
|
|
81
|
+
return Condition(self, "@", other)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NestedField(Field[T]):
|
|
85
|
+
"""Field representing a nested path like role.entity."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, path: str, parent_class: str, field_type: Type[T]):
|
|
88
|
+
self.path = path
|
|
89
|
+
self.parent_class = parent_class
|
|
90
|
+
self.field_type = field_type
|
|
91
|
+
# Use the full path as the name
|
|
92
|
+
super().__init__(path, parent_class, field_type)
|
|
93
|
+
|
|
94
|
+
def __repr__(self):
|
|
95
|
+
return f"{self.parent_class}.{self.path}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NestedFieldDescriptor:
|
|
99
|
+
"""Descriptor for nested field access in queries."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, field_name: str, parent_class: str, nested_class: type):
|
|
102
|
+
self.field_name = field_name
|
|
103
|
+
self.parent_class = parent_class
|
|
104
|
+
self.nested_class = nested_class
|
|
105
|
+
|
|
106
|
+
# Create field descriptors for the nested class
|
|
107
|
+
if hasattr(nested_class, 'model_fields'):
|
|
108
|
+
for nested_field_name, nested_field_info in nested_class.model_fields.items():
|
|
109
|
+
nested_field_type = nested_field_info.annotation if isinstance(nested_field_info, FieldInfo) else nested_field_info.return_type
|
|
110
|
+
# Create a custom field that represents the nested path
|
|
111
|
+
nested_field = NestedField(f"{field_name}.{nested_field_name}", parent_class, nested_field_type)
|
|
112
|
+
setattr(self, nested_field_name, nested_field)
|
|
113
|
+
|
|
114
|
+
def __eq__(self, other):
|
|
115
|
+
"""Allow direct comparison on the nested field."""
|
|
116
|
+
field = Field(self.field_name, self.parent_class, self.nested_class)
|
|
117
|
+
return Condition(field, "==", other)
|
|
118
|
+
|
|
119
|
+
def __ne__(self, other):
|
|
120
|
+
field = Field(self.field_name, self.parent_class, self.nested_class)
|
|
121
|
+
return Condition(field, "!=", other)
|
|
122
|
+
|
|
123
|
+
def __matmul__(self, other):
|
|
124
|
+
"""Allow fulltext search on the nested field."""
|
|
125
|
+
field = Field(self.field_name, self.parent_class, self.nested_class)
|
|
126
|
+
return Condition(field, "@", other)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Direction(Enum):
|
|
130
|
+
"""Graph traversal direction."""
|
|
131
|
+
FORWARD = "->"
|
|
132
|
+
BACKWARD = "<-"
|
|
133
|
+
BIDIRECTIONAL = "<->"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class Condition:
|
|
141
|
+
"""Represents a query condition."""
|
|
142
|
+
|
|
143
|
+
left: Any
|
|
144
|
+
operator: str
|
|
145
|
+
right: Any
|
|
146
|
+
|
|
147
|
+
def __and__(self, other: Condition) -> LogicalCondition:
|
|
148
|
+
"""Combine conditions with AND."""
|
|
149
|
+
return LogicalCondition(self, "AND", other)
|
|
150
|
+
|
|
151
|
+
def __or__(self, other: Condition) -> LogicalCondition:
|
|
152
|
+
"""Combine conditions with OR."""
|
|
153
|
+
return LogicalCondition(self, "OR", other)
|
|
154
|
+
|
|
155
|
+
def __invert__(self) -> NotCondition:
|
|
156
|
+
"""Negate the condition."""
|
|
157
|
+
return NotCondition(self)
|
|
158
|
+
|
|
159
|
+
def to_surreal(self) -> str:
|
|
160
|
+
"""Convert to SurrealQL syntax."""
|
|
161
|
+
left_str = self._format_value(self.left)
|
|
162
|
+
right_str = self._format_value(self.right)
|
|
163
|
+
|
|
164
|
+
# Handle different operator types
|
|
165
|
+
if self.operator in ("=", "=="):
|
|
166
|
+
# Use = for RecordID comparisons, IS for other values
|
|
167
|
+
if isinstance(self.right, RecordID) or (hasattr(self.right, "id") and isinstance(self.right.id, RecordID)):
|
|
168
|
+
op = "="
|
|
169
|
+
else:
|
|
170
|
+
op = "IS"
|
|
171
|
+
elif self.operator == "!=":
|
|
172
|
+
if isinstance(self.right, RecordID) or (hasattr(self.right, "id") and isinstance(self.right.id, RecordID)):
|
|
173
|
+
op = "!="
|
|
174
|
+
else:
|
|
175
|
+
op = "IS NOT"
|
|
176
|
+
elif self.operator == "@":
|
|
177
|
+
# Fulltext search operator - SurrealDB uses @N@ syntax where N is the index position
|
|
178
|
+
# We default to @0@ for the first fulltext index
|
|
179
|
+
op = "@0@"
|
|
180
|
+
else:
|
|
181
|
+
op = self.operator
|
|
182
|
+
|
|
183
|
+
return f"{left_str} {op} {right_str}"
|
|
184
|
+
|
|
185
|
+
def _format_value(self, value: Any) -> str:
|
|
186
|
+
"""Format a value for SurrealQL."""
|
|
187
|
+
if isinstance(value, Field):
|
|
188
|
+
return value.name
|
|
189
|
+
elif isinstance(value, RecordID):
|
|
190
|
+
return f"{value.table_name}:{value.id}"
|
|
191
|
+
elif hasattr(value, "id") and isinstance(value.id, RecordID):
|
|
192
|
+
return f"{value.id.table_name}:{value.id.id}"
|
|
193
|
+
elif isinstance(value, str):
|
|
194
|
+
# Check if it's a field reference or a literal string
|
|
195
|
+
if "." in value and not value.startswith('"'):
|
|
196
|
+
return value # Field reference
|
|
197
|
+
return f'"{value}"' # String literal
|
|
198
|
+
elif isinstance(value, (int, float)):
|
|
199
|
+
return str(value)
|
|
200
|
+
elif isinstance(value, bool):
|
|
201
|
+
return "true" if value else "false"
|
|
202
|
+
elif value is None:
|
|
203
|
+
return "NULL"
|
|
204
|
+
elif hasattr(value, '__class__') and hasattr(value.__class__, '__mro__'):
|
|
205
|
+
# Check if it's an Enum
|
|
206
|
+
if Enum in value.__class__.__mro__:
|
|
207
|
+
# Use the enum's value if it's a string, otherwise use its name
|
|
208
|
+
if isinstance(value.value, str):
|
|
209
|
+
return f'"{value.value}"'
|
|
210
|
+
else:
|
|
211
|
+
return f'"{value.name}"'
|
|
212
|
+
return str(value)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class LogicalCondition:
|
|
217
|
+
"""Represents a logical combination of conditions."""
|
|
218
|
+
|
|
219
|
+
left: Union[Condition, LogicalCondition]
|
|
220
|
+
operator: str
|
|
221
|
+
right: Union[Condition, LogicalCondition]
|
|
222
|
+
|
|
223
|
+
def __and__(self, other: Union[Condition, LogicalCondition]) -> LogicalCondition:
|
|
224
|
+
"""Combine with another condition using AND."""
|
|
225
|
+
return LogicalCondition(self, "AND", other)
|
|
226
|
+
|
|
227
|
+
def __or__(self, other: Union[Condition, LogicalCondition]) -> LogicalCondition:
|
|
228
|
+
"""Combine with another condition using OR."""
|
|
229
|
+
return LogicalCondition(self, "OR", other)
|
|
230
|
+
|
|
231
|
+
def to_surreal(self) -> str:
|
|
232
|
+
"""Convert to SurrealQL syntax."""
|
|
233
|
+
left_str = self.left.to_surreal()
|
|
234
|
+
right_str = self.right.to_surreal()
|
|
235
|
+
return f"({left_str} {self.operator} {right_str})"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass
|
|
239
|
+
class NotCondition:
|
|
240
|
+
"""Represents a negated condition."""
|
|
241
|
+
|
|
242
|
+
condition: Union[Condition, LogicalCondition]
|
|
243
|
+
|
|
244
|
+
def to_surreal(self) -> str:
|
|
245
|
+
"""Convert to SurrealQL syntax."""
|
|
246
|
+
return f"NOT ({self.condition.to_surreal()})"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class Traversal:
|
|
251
|
+
"""Represents a graph traversal operation."""
|
|
252
|
+
|
|
253
|
+
direction: Direction
|
|
254
|
+
target: Union[Type[Node], Type[Edge], str]
|
|
255
|
+
recursion_depth: int = 1
|
|
256
|
+
select_fields: Optional[str] = None
|
|
257
|
+
where_condition: Optional[Union[Condition, LogicalCondition]] = None
|
|
258
|
+
|
|
259
|
+
def recurse(self, depth: int) -> Traversal:
|
|
260
|
+
"""Set recursion depth for this traversal."""
|
|
261
|
+
self.recursion_depth = depth
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
def select(self, fields: str = "*") -> Traversal:
|
|
265
|
+
"""Select specific fields from the traversal result."""
|
|
266
|
+
self.select_fields = fields
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
def where(self, condition: Union[Condition, LogicalCondition]) -> Traversal:
|
|
270
|
+
"""Add a WHERE condition to this traversal."""
|
|
271
|
+
self.where_condition = condition
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
def to_surreal(self) -> str:
|
|
275
|
+
"""Convert to SurrealQL syntax."""
|
|
276
|
+
# Import here to avoid circular imports at module level
|
|
277
|
+
from .node import Node
|
|
278
|
+
|
|
279
|
+
target_name = self._get_target_name()
|
|
280
|
+
|
|
281
|
+
# Build the basic traversal with optional WHERE clause
|
|
282
|
+
where_clause = f"[WHERE {self.where_condition.to_surreal()} ]" if self.where_condition else ""
|
|
283
|
+
|
|
284
|
+
if self.direction == Direction.BACKWARD:
|
|
285
|
+
result = f"<-{target_name}{where_clause}"
|
|
286
|
+
elif self.direction == Direction.FORWARD:
|
|
287
|
+
result = f"->{target_name}{where_clause}"
|
|
288
|
+
else: # BIDIRECTIONAL
|
|
289
|
+
result = f"<->{target_name}{where_clause}"
|
|
290
|
+
|
|
291
|
+
# Add recursion if needed
|
|
292
|
+
if self.recursion_depth > 1:
|
|
293
|
+
result = f".{{{self.recursion_depth}}}({result})"
|
|
294
|
+
|
|
295
|
+
# Check if target is a Node class to determine field selection
|
|
296
|
+
is_node = False
|
|
297
|
+
if isinstance(self.target, type):
|
|
298
|
+
try:
|
|
299
|
+
is_node = issubclass(self.target, Node)
|
|
300
|
+
except:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
# Add field selection or default .* for nodes
|
|
304
|
+
if self.select_fields:
|
|
305
|
+
result += f".{self.select_fields}"
|
|
306
|
+
elif is_node:
|
|
307
|
+
result += ".*"
|
|
308
|
+
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
def _get_target_name(self) -> str:
|
|
312
|
+
"""Get the name of the target table/edge."""
|
|
313
|
+
if isinstance(self.target, type):
|
|
314
|
+
return self.target.__name__.lower()
|
|
315
|
+
return str(self.target)
|
tapestry/field.pyi
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TypeVar, Generic, Type, Union, Any, Optional, overload
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from surrealdb import RecordID
|
|
7
|
+
|
|
8
|
+
from .node import Node
|
|
9
|
+
from .edge import Edge
|
|
10
|
+
|
|
11
|
+
T = TypeVar('T')
|
|
12
|
+
|
|
13
|
+
class Field(Generic[T]):
|
|
14
|
+
name: str
|
|
15
|
+
model_class: str
|
|
16
|
+
field_type: Type[T]
|
|
17
|
+
|
|
18
|
+
def __init__(self, name: str, model_class: str, field_type: Type[T]) -> None: ...
|
|
19
|
+
def __repr__(self) -> str: ...
|
|
20
|
+
def __eq__(self, other: object) -> Condition: ... # type: ignore[override]
|
|
21
|
+
def __ne__(self, other: object) -> Condition: ... # type: ignore[override]
|
|
22
|
+
def __lt__(self, other: T) -> Condition: ...
|
|
23
|
+
def __le__(self, other: T) -> Condition: ...
|
|
24
|
+
def __gt__(self, other: T) -> Condition: ...
|
|
25
|
+
def __ge__(self, other: T) -> Condition: ...
|
|
26
|
+
def __matmul__(self, other: T) -> Condition: ...
|
|
27
|
+
|
|
28
|
+
class ComputedFieldDescriptor(Field[T]):
|
|
29
|
+
original_descriptor: Optional[property]
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
name: str,
|
|
34
|
+
model_class: str,
|
|
35
|
+
field_type: Type[T],
|
|
36
|
+
original_descriptor: Optional[property] = None
|
|
37
|
+
) -> None: ...
|
|
38
|
+
def __get__(self, instance: Any, owner: Type[Any]) -> T | ComputedFieldDescriptor[T]: ...
|
|
39
|
+
def __set__(self, instance: Any, value: T) -> None: ...
|
|
40
|
+
def __delete__(self, instance: Any) -> None: ...
|
|
41
|
+
def __matmul__(self, other: T) -> Condition: ...
|
|
42
|
+
|
|
43
|
+
class NestedField(Field[T]):
|
|
44
|
+
path: str
|
|
45
|
+
parent_class: str
|
|
46
|
+
|
|
47
|
+
def __init__(self, path: str, parent_class: str, field_type: Type[T]) -> None: ...
|
|
48
|
+
def __repr__(self) -> str: ...
|
|
49
|
+
|
|
50
|
+
class NestedFieldDescriptor:
|
|
51
|
+
field_name: str
|
|
52
|
+
parent_class: str
|
|
53
|
+
nested_class: type
|
|
54
|
+
|
|
55
|
+
def __init__(self, field_name: str, parent_class: str, nested_class: type) -> None: ...
|
|
56
|
+
def __eq__(self, other: object) -> Condition: ... # type: ignore[override]
|
|
57
|
+
def __ne__(self, other: object) -> Condition: ... # type: ignore[override]
|
|
58
|
+
def __matmul__(self, other: Any) -> Condition: ...
|
|
59
|
+
|
|
60
|
+
class Direction(Enum):
|
|
61
|
+
FORWARD = "->"
|
|
62
|
+
BACKWARD = "<-"
|
|
63
|
+
BIDIRECTIONAL = "<->"
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Condition:
|
|
67
|
+
left: Any
|
|
68
|
+
operator: str
|
|
69
|
+
right: Any
|
|
70
|
+
|
|
71
|
+
def __and__(self, other: Condition) -> LogicalCondition: ...
|
|
72
|
+
def __or__(self, other: Condition) -> LogicalCondition: ...
|
|
73
|
+
def __invert__(self) -> NotCondition: ...
|
|
74
|
+
def to_surreal(self) -> str: ...
|
|
75
|
+
def _format_value(self, value: Any) -> str: ...
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class LogicalCondition:
|
|
79
|
+
left: Union[Condition, LogicalCondition]
|
|
80
|
+
operator: str
|
|
81
|
+
right: Union[Condition, LogicalCondition]
|
|
82
|
+
|
|
83
|
+
def __and__(self, other: Union[Condition, LogicalCondition]) -> LogicalCondition: ...
|
|
84
|
+
def __or__(self, other: Union[Condition, LogicalCondition]) -> LogicalCondition: ...
|
|
85
|
+
def to_surreal(self) -> str: ...
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class NotCondition:
|
|
89
|
+
condition: Union[Condition, LogicalCondition]
|
|
90
|
+
|
|
91
|
+
def to_surreal(self) -> str: ...
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Traversal:
|
|
95
|
+
direction: Direction
|
|
96
|
+
target: Union[Type[Node], Type[Edge], str]
|
|
97
|
+
recursion_depth: int
|
|
98
|
+
select_fields: Optional[str]
|
|
99
|
+
where_condition: Optional[Union[Condition, LogicalCondition]]
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
direction: Direction,
|
|
104
|
+
target: Union[Type[Node], Type[Edge], str],
|
|
105
|
+
recursion_depth: int = 1,
|
|
106
|
+
select_fields: Optional[str] = None,
|
|
107
|
+
where_condition: Optional[Union[Condition, LogicalCondition]] = None
|
|
108
|
+
) -> None: ...
|
|
109
|
+
def recurse(self, depth: int) -> Traversal: ...
|
|
110
|
+
def select(self, fields: str = "*") -> Traversal: ...
|
|
111
|
+
def where(self, condition: Union[Condition, LogicalCondition]) -> Traversal: ...
|
|
112
|
+
def to_surreal(self) -> str: ...
|
|
113
|
+
def _get_target_name(self) -> str: ...
|
tapestry/node.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from typing import Self, Iterable, ClassVar
|
|
2
|
+
from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection, RecordID
|
|
3
|
+
|
|
4
|
+
from .base import Base
|
|
5
|
+
|
|
6
|
+
class Node(Base, discarded=True):
|
|
7
|
+
"""
|
|
8
|
+
Base class for SurrealDB node (non-relation) tables.
|
|
9
|
+
|
|
10
|
+
Node represents standard tables in SurrealDB that are not relationships.
|
|
11
|
+
All regular tables should inherit from this class to gain database
|
|
12
|
+
operation methods like insert, create, and save.
|
|
13
|
+
|
|
14
|
+
Class Attributes:
|
|
15
|
+
_is_relation: Always False for Node tables
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
>>> from tapestry import Node
|
|
19
|
+
>>> from datetime import date
|
|
20
|
+
>>>
|
|
21
|
+
>>> class Person(Node):
|
|
22
|
+
... first_name: str
|
|
23
|
+
... last_name: str
|
|
24
|
+
... date_of_birth: date
|
|
25
|
+
...
|
|
26
|
+
>>> # Create instances
|
|
27
|
+
>>> person = Person(
|
|
28
|
+
... first_name="John",
|
|
29
|
+
... last_name="Doe",
|
|
30
|
+
... date_of_birth=date(1990, 1, 1)
|
|
31
|
+
... )
|
|
32
|
+
...
|
|
33
|
+
>>> # Insert into database
|
|
34
|
+
>>> async with db_session as db:
|
|
35
|
+
... await person.create(db)
|
|
36
|
+
|
|
37
|
+
Notes:
|
|
38
|
+
- Provides CRUD operations for non-relation tables
|
|
39
|
+
- Automatically handles ID assignment from database
|
|
40
|
+
- Supports batch inserts for efficiency
|
|
41
|
+
- Works with SurrealDB's SCHEMAFULL tables
|
|
42
|
+
"""
|
|
43
|
+
_is_relation: ClassVar[bool] = False
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
async def insert(
|
|
47
|
+
cls,
|
|
48
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection, others: Iterable[Self]
|
|
49
|
+
) -> list[Self]:
|
|
50
|
+
"""
|
|
51
|
+
Batch insert multiple records into the database.
|
|
52
|
+
|
|
53
|
+
Efficiently inserts multiple instances of the model into SurrealDB
|
|
54
|
+
in a single operation. The original instances are updated with their
|
|
55
|
+
assigned IDs from the database.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
db: Active SurrealDB connection (WebSocket or HTTP)
|
|
59
|
+
others: Iterable of model instances to insert
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
list[Self]: The same instances with IDs assigned
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
>>> people = [
|
|
66
|
+
... Person(first_name="John", last_name="Doe"),
|
|
67
|
+
... Person(first_name="Jane", last_name="Smith"),
|
|
68
|
+
... ]
|
|
69
|
+
>>> inserted = await Person.insert(db, people)
|
|
70
|
+
>>> # All instances now have IDs assigned
|
|
71
|
+
>>> for person in inserted:
|
|
72
|
+
... print(person.id)
|
|
73
|
+
|
|
74
|
+
Notes:
|
|
75
|
+
- More efficient than multiple create() calls
|
|
76
|
+
- Original instances are modified in place with IDs
|
|
77
|
+
- All records are inserted in a single database transaction
|
|
78
|
+
"""
|
|
79
|
+
others = list(others)
|
|
80
|
+
inserted = await db.insert(
|
|
81
|
+
cls.__name__.lower(),
|
|
82
|
+
[other.db_dump() for other in others]
|
|
83
|
+
)
|
|
84
|
+
# Simply update IDs from response
|
|
85
|
+
for insert, original in zip(inserted, others):
|
|
86
|
+
original.id = insert["id"]
|
|
87
|
+
return others
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def create(
|
|
91
|
+
self,
|
|
92
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
|
|
93
|
+
) -> Self:
|
|
94
|
+
"""
|
|
95
|
+
Create a single record in the database.
|
|
96
|
+
|
|
97
|
+
Inserts this instance into SurrealDB and updates it with the
|
|
98
|
+
assigned record ID.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
db: Active SurrealDB connection (WebSocket or HTTP)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Self: The same instance with ID assigned
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
>>> person = Person(
|
|
108
|
+
... first_name="John",
|
|
109
|
+
... last_name="Doe",
|
|
110
|
+
... date_of_birth=date(1990, 1, 1)
|
|
111
|
+
... )
|
|
112
|
+
>>> await person.create(db)
|
|
113
|
+
>>> print(person.id) # Now has an ID like person:ulid
|
|
114
|
+
|
|
115
|
+
Notes:
|
|
116
|
+
- If the instance already has an ID, it will be used
|
|
117
|
+
- Otherwise, SurrealDB generates a new unique ID
|
|
118
|
+
- The instance is modified in place
|
|
119
|
+
"""
|
|
120
|
+
thing = self.id if isinstance(self.id, RecordID) else self.__class__.__name__.lower()
|
|
121
|
+
creation = await db.create(
|
|
122
|
+
thing,
|
|
123
|
+
self.db_dump()
|
|
124
|
+
)
|
|
125
|
+
assert isinstance(creation, dict)
|
|
126
|
+
self.id = creation["id"]
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def save(
|
|
131
|
+
self,
|
|
132
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
|
|
133
|
+
) -> Self:
|
|
134
|
+
"""
|
|
135
|
+
Update an existing record in the database.
|
|
136
|
+
|
|
137
|
+
Saves the current state of this instance to SurrealDB, replacing
|
|
138
|
+
the existing record with the same ID.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
db: Active SurrealDB connection (WebSocket or HTTP)
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Self: The same instance, potentially with updated fields
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
>>> # Fetch an existing person
|
|
148
|
+
>>> person = await Person.get(db, "person:123")
|
|
149
|
+
>>> # Modify fields
|
|
150
|
+
>>> person.first_name = "Jane"
|
|
151
|
+
>>> # Save changes to database
|
|
152
|
+
>>> await person.save(db)
|
|
153
|
+
|
|
154
|
+
Notes:
|
|
155
|
+
- Requires the instance to have an ID (from create() or query)
|
|
156
|
+
- Performs a full replacement of the record
|
|
157
|
+
- Use for updating existing records after modifications
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
AssertionError: If the database operation returns unexpected data
|
|
161
|
+
"""
|
|
162
|
+
thing = self.id if isinstance(self.id, RecordID) else self.__class__.__name__.lower()
|
|
163
|
+
creation = await db.update(
|
|
164
|
+
thing,
|
|
165
|
+
self.db_dump()
|
|
166
|
+
)
|
|
167
|
+
assert isinstance(creation, dict)
|
|
168
|
+
self.id = creation["id"]
|
|
169
|
+
return self
|
tapestry/node.pyi
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar, Iterable, Self, TypeVar
|
|
4
|
+
from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection
|
|
5
|
+
|
|
6
|
+
from .base import Base
|
|
7
|
+
|
|
8
|
+
T = TypeVar('T', bound='Node')
|
|
9
|
+
|
|
10
|
+
class Node(Base):
|
|
11
|
+
_is_relation: ClassVar[bool]
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
async def insert(
|
|
15
|
+
cls: type[T],
|
|
16
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection,
|
|
17
|
+
others: Iterable[T]
|
|
18
|
+
) -> list[T]: ...
|
|
19
|
+
|
|
20
|
+
async def create(
|
|
21
|
+
self: T,
|
|
22
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
|
|
23
|
+
) -> T: ...
|
|
24
|
+
|
|
25
|
+
async def save(
|
|
26
|
+
self: T,
|
|
27
|
+
db: AsyncWsSurrealConnection | AsyncHttpSurrealConnection
|
|
28
|
+
) -> T: ...
|
tapestry/py.typed
ADDED
|
File without changes
|