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/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