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 ADDED
@@ -0,0 +1,11 @@
1
+ __all__ = [
2
+ "Base", "Text", "create_engine", "Reference", "Node", "Edge", "Q"
3
+ ]
4
+
5
+ from .query import Q
6
+ from .base import Base
7
+ from .node import Node
8
+ from .edge import Edge
9
+ from .tokenizer import Text
10
+ from .table import Reference
11
+ from .engine import create_engine
tapestry/base.py ADDED
@@ -0,0 +1,401 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+
5
+ from enum import Enum
6
+ from copy import deepcopy
7
+ from itertools import chain
8
+ from queue import LifoQueue
9
+ from pydantic_core.core_schema import ValidationInfo
10
+ from pydantic.fields import FieldInfo, ComputedFieldInfo
11
+ from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection, RecordID
12
+ from typing import Any, ClassVar, Optional, Self, Any, Unpack, Union, Type, get_args
13
+ from pydantic import BaseModel, field_validator, model_serializer, field_serializer, Field, ConfigDict, model_validator
14
+
15
+ from .table import Table, Reference
16
+ from .utils import convert_types, replace_type, flatten_type
17
+ from .field import Field as CustomField, NestedFieldDescriptor, ComputedFieldDescriptor
18
+
19
+
20
+ # Registry base class -------------------------------------------------------
21
+ class Base(BaseModel):
22
+ """
23
+ Base class for creating SurrealDB table models with Pydantic.
24
+
25
+ The Base class provides the foundation for defining SurrealDB tables as Python classes
26
+ using Pydantic models. All table models should inherit from this class, which handles
27
+ automatic registration, schema generation, and serialization/deserialization.
28
+
29
+ Attributes:
30
+ id (RecordID | None): The SurrealDB record ID. Automatically assigned when records
31
+ are created in the database. Can be None for new records.
32
+
33
+ Class Attributes:
34
+ _registry: Tuple of all registered tables in the system
35
+ _tokenizers: Set of tokenizer definitions for full-text search
36
+ _to_create: Queue for chaining object creations
37
+ child_classes: Dictionary mapping table names to their model classes
38
+
39
+ Example:
40
+ >>> from tapestry import Base
41
+ >>> from datetime import date
42
+ >>>
43
+ >>> class Person(Base):
44
+ ... first_name: str
45
+ ... last_name: str
46
+ ... date_of_birth: date
47
+ ...
48
+ >>> # Person is automatically registered and can generate SurrealDB schema
49
+ >>> schema = Base.generate_schema()
50
+
51
+ Notes:
52
+ - Subclasses are automatically registered upon definition
53
+ - Field types are automatically mapped to SurrealDB types
54
+ - Supports relationships through Reference fields
55
+ - Handles enum serialization automatically
56
+ - Provides full-text search capabilities with Text fields
57
+ """
58
+ id: RecordID | None = Field(exclude=False, default = None)
59
+
60
+ model_config = {
61
+ "arbitrary_types_allowed": True,
62
+ "use_enum_values": False, # would be great to have a 'use_enum_names'
63
+ "validate_assignment": True
64
+ }
65
+ _registry: ClassVar[tuple[Table, ...]] = ()
66
+ _tokenizers: ClassVar[set[str]] = set()
67
+ _to_create: ClassVar[LifoQueue[Base]]
68
+ child_classes: ClassVar[dict[str, Type[Base]]] = {}
69
+
70
+
71
+ @classmethod
72
+ @field_validator('id')
73
+ def validate_record_id(cls, v: Any) -> Optional[RecordID]:
74
+ """
75
+ Validate and convert record ID values.
76
+
77
+ Args:
78
+ v: The value to validate (can be None, RecordID, or string)
79
+
80
+ Returns:
81
+ Optional[RecordID]: A valid RecordID or None
82
+
83
+ Notes:
84
+ Automatically converts string IDs to RecordID instances with the
85
+ appropriate table name.
86
+ """
87
+ if v is None:
88
+ return None
89
+ if isinstance(v, RecordID):
90
+ return v
91
+ # Convert string to RecordID if needed
92
+ return RecordID(cls.__name__.lower(), v)
93
+
94
+
95
+ @model_serializer(mode="wrap")
96
+ def _serialize(self, serializer, info):
97
+ """
98
+ Custom serializer for handling record references.
99
+
100
+ When serializing nested objects, this method ensures that only the record ID
101
+ is serialized for referenced records, not the entire object.
102
+
103
+ Args:
104
+ serializer: The default Pydantic serializer
105
+ info: Serialization context information
106
+
107
+ Returns:
108
+ Serialized representation of the object or just its ID for references
109
+
110
+ Raises:
111
+ Exception: If attempting to reference a record that hasn't been created yet
112
+ """
113
+ if not info.context:
114
+ return serializer(self)
115
+ if info.context.get("root"):
116
+ info.context["root"] = False
117
+ return serializer(self)
118
+ else:
119
+ if self.id is None:
120
+ raise Exception(f"You should create your record before referecing to it : create {self}")
121
+ # self._to_create.put(self)
122
+ # maybe check here in context if we are inserting in db
123
+ return self.id
124
+
125
+
126
+ @field_validator('*', mode='before')
127
+ @classmethod
128
+ def validate_enums(cls, v: Any, info: ValidationInfo) -> Any:
129
+ """
130
+ Universal validator for enum fields and record references.
131
+
132
+ Automatically handles:
133
+ - Converting enum names (strings) to enum instances
134
+ - Validating enum values
135
+ - Processing RecordID references
136
+
137
+ Args:
138
+ v: The value to validate
139
+ info: Validation context with field information
140
+
141
+ Returns:
142
+ The validated/converted value
143
+ """
144
+ constructor = cls._table.name_to_type.get(info.field_name)
145
+ if inspect.isclass(constructor) and issubclass(constructor, Enum):
146
+ if isinstance(v, constructor):
147
+ return v
148
+ name = constructor[v]
149
+ return name
150
+ # if isinstance(v, RecordID) and not info.field_name == "id":
151
+ # try:
152
+ # constructor = cls.child_classes[v.table_name]
153
+ # except KeyError:
154
+ # return v
155
+ # values = {key: None for key in constructor.model_fields}
156
+ # values["id"] = v
157
+ # stub = constructor.model_construct(**values)
158
+ # # def _frozen_setattr(self, name, value):
159
+ # # raise AttributeError(f"Instance {type(self).__name__} is frozen; cannot set {name!r}")
160
+ # # stub.__setattr__ = MethodType(_frozen_setattr, stub)
161
+ # print("stub : ", stub, constructor)
162
+ # return stub
163
+ return v
164
+
165
+
166
+ def __init_subclass__(cls, **kwargs: Unpack[ConfigDict]):
167
+ discarded = kwargs.pop("discarded", False)
168
+ # Create unique queue for each subclass
169
+ # this queue will be used to chain several object creations
170
+ cls._to_create = LifoQueue()
171
+ cls._discarded = discarded
172
+ return super().__init_subclass__(**kwargs)
173
+
174
+
175
+ @classmethod
176
+ def __pydantic_init_subclass__(cls, **kwargs):
177
+ relation = kwargs.pop("relation", None)
178
+ super().__pydantic_init_subclass__(**kwargs)
179
+
180
+ if cls._discarded:
181
+ return
182
+
183
+ model_fields: dict[str, FieldInfo | ComputedFieldInfo] = deepcopy(cls.model_fields)
184
+ model_fields.update(cls.model_computed_fields)
185
+ fields: list[CustomField[Any]] = []
186
+ for field_name, field_info in model_fields.items():
187
+ field_type = field_info.annotation if isinstance(field_info, FieldInfo) else field_info.return_type
188
+ if isinstance(field_info, ComputedFieldInfo):
189
+ field_descriptor = ComputedFieldDescriptor[field_type](field_name, cls.__name__.lower(), field_type or Any, getattr(cls, field_name, None))
190
+ else:
191
+ field_descriptor = CustomField[field_type](field_name, cls.__name__.lower(), field_type or Any)
192
+ fields.append(field_descriptor)
193
+
194
+ # Handle different field types
195
+ nested_class = None
196
+ try:
197
+ # Check if it's a Base subclass directly
198
+ if field_type and hasattr(field_type, '__mro__') and Base in field_type.__mro__:
199
+ nested_class = field_type
200
+ # Check if it's Optional[Base subclass] (Union with None)
201
+ elif hasattr(field_type, '__origin__') and field_type.__origin__ is Union:
202
+ # Get the args of the Union
203
+ args = getattr(field_type, '__args__', ())
204
+ # Filter out None and check if any remaining type is a Base subclass
205
+ for arg in args:
206
+ if arg is not type(None) and hasattr(arg, '__mro__') and Base in arg.__mro__:
207
+ nested_class = arg
208
+ break
209
+
210
+ if nested_class:
211
+ # Create a nested field descriptor that allows chaining
212
+ nested_descriptor = NestedFieldDescriptor(field_name, cls.__name__.lower(), nested_class)
213
+ setattr(cls, field_name, nested_descriptor)
214
+ else:
215
+ setattr(cls, field_name, field_descriptor)
216
+ except (TypeError, AttributeError):
217
+ # For other complex types, just use the field descriptor
218
+ setattr(cls, field_name, field_descriptor)
219
+
220
+ # replace Base instance with Union[annotation, RecordID]
221
+ if field_name in cls.model_fields:
222
+ cls.model_fields[field_name].annotation = replace_type(cls.model_fields[field_name].annotation, Base, Union[Base, RecordID])
223
+
224
+
225
+ table = Table(name=cls.__name__.lower(), base_class=Base, model_class=cls, fields=tuple(fields), relation=relation)
226
+ for tokenizer in table.tokenizers:
227
+ Base._tokenizers.add(tokenizer.define())
228
+
229
+ cls._table = table
230
+ # I want to keep _registry as immutable as possible and only append data to it
231
+ Base.add_table(table, cls)
232
+ cls.model_rebuild(force=True)
233
+
234
+
235
+ @classmethod
236
+ def registered_tables(cls) -> set[str]:
237
+ """
238
+ Get the names of all registered tables.
239
+
240
+ Returns:
241
+ set[str]: Set of table names that have been registered
242
+
243
+ Example:
244
+ >>> tables = Base.registered_tables()
245
+ >>> print(tables)
246
+ {'person', 'entity', 'role', ...}
247
+ """
248
+ return {f.name for f in cls._registry}
249
+
250
+
251
+ @classmethod
252
+ def add_table(cls, table: Table, child_class: Type[Base]):
253
+ """
254
+ Register a new table in the system registry.
255
+
256
+ Args:
257
+ table: The Table definition to register
258
+ child_class: The model class associated with the table
259
+
260
+ Notes:
261
+ This is called automatically when subclasses are defined.
262
+ Users typically don't need to call this directly.
263
+ """
264
+ cls._registry = tuple(t for t in chain(cls._registry, (table, )))
265
+ cls.child_classes[table.name] = child_class
266
+
267
+
268
+ @classmethod
269
+ def registered_models(cls) -> list[Table]:
270
+ """
271
+ Get all registered table definitions.
272
+
273
+ Returns:
274
+ list[Table]: List of Table objects that have been registered
275
+
276
+ Example:
277
+ >>> models = Base.registered_models()
278
+ >>> for model in models:
279
+ ... print(f"Table: {model.name}")
280
+ """
281
+ return list(cls._registry)
282
+
283
+
284
+ @classmethod
285
+ def generate_schema(cls) -> str:
286
+ """
287
+ Generate complete SurrealQL schema for all registered tables.
288
+
289
+ Creates the SQL statements needed to define all tables, fields, indexes,
290
+ and tokenizers in SurrealDB. This should be executed when setting up
291
+ a new database or updating the schema.
292
+
293
+ Returns:
294
+ str: Complete SurrealQL schema definition
295
+
296
+ Example:
297
+ >>> async with AsyncSurreal(url) as db:
298
+ ... await db.signin({"username": "root", "password": "root"})
299
+ ... await db.use("mydb", "myns")
300
+ ... schema = Base.generate_schema()
301
+ ... await db.query(schema)
302
+
303
+ Notes:
304
+ - Includes table definitions with SCHEMAFULL
305
+ - Defines all fields with proper types
306
+ - Sets up full-text search indexes
307
+ - Configures tokenizers for text analysis
308
+ - Creates relationship constraints
309
+ """
310
+ blocks = [t.generate_table_sql() for t in cls._registry]
311
+ return "\n\n".join(chain(cls._tokenizers, blocks))
312
+
313
+
314
+ @classmethod
315
+ def deserialize_record(cls, data: dict) -> Any:
316
+ """
317
+ Deserialize a SurrealDB record into a Pydantic model instance.
318
+
319
+ Automatically converts SurrealDB records to the appropriate model class
320
+ based on the record's table name.
321
+
322
+ Args:
323
+ data: Dictionary containing the record data from SurrealDB
324
+
325
+ Returns:
326
+ An instance of the appropriate model class, or the original data
327
+ if no matching model is found
328
+
329
+ Notes:
330
+ - Handles edge records by converting 'in' and 'out' to 'in_' and 'out_'
331
+ - Automatically determines the model class from the record ID
332
+ - Validates data using Pydantic validation
333
+ """
334
+ if not isinstance(data, dict):
335
+ return data
336
+
337
+ if "in" in data and "out" in data:
338
+ data["in_"] = data.pop("in", None)
339
+ data["out_"] = data.pop("out", None)
340
+
341
+ # Get the model class from the record ID
342
+ record_id = data.get('id')
343
+ if not record_id or not isinstance(record_id, RecordID):
344
+ return data
345
+
346
+ model_class = cls.child_classes.get(record_id.table_name)
347
+ if not model_class:
348
+ return data
349
+
350
+ # The model validator will handle RecordID conversion
351
+ # here maybe specify if the users wants related records as ids or full objects
352
+ return model_class.model_validate(data, context="could this parameter be of any use ?")
353
+
354
+
355
+ @classmethod
356
+ def deserialize_response(cls, response: Any) -> Any:
357
+ """
358
+ Deserialize a complete SurrealDB response.
359
+
360
+ Recursively processes responses to convert all records to their
361
+ appropriate model instances.
362
+
363
+ Args:
364
+ response: The response from SurrealDB (can be list, dict, or primitive)
365
+
366
+ Returns:
367
+ The deserialized response with records converted to model instances
368
+
369
+ Example:
370
+ >>> result = await db.select("person")
371
+ >>> people = Base.deserialize_response(result)
372
+ >>> # people is now a list of Person instances
373
+ """
374
+ if isinstance(response, list):
375
+ return [cls.deserialize_record(item) if isinstance(item, dict) else item for item in response]
376
+ elif isinstance(response, dict):
377
+ return cls.deserialize_record(response)
378
+ return response
379
+
380
+
381
+ def db_dump(self) -> dict[str, Any]:
382
+ """
383
+ Serialize the model instance for database insertion/update.
384
+
385
+ Prepares the model data for sending to SurrealDB by:
386
+ - Removing the ID field (handled separately by SurrealDB)
387
+ - Converting Python types to SurrealDB-compatible formats
388
+ - Serializing nested objects appropriately
389
+
390
+ Returns:
391
+ dict[str, Any]: Dictionary ready for database operations
392
+
393
+ Example:
394
+ >>> person = Person(first_name="John", last_name="Doe")
395
+ >>> data = person.db_dump()
396
+ >>> await db.create("person", data)
397
+ """
398
+ serialized = self.model_dump(context={"root": True})
399
+ serialized.pop('id', None)
400
+ # this convert_types fonction is only here
401
+ return convert_types(serialized)
tapestry/base.pyi ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, ClassVar, Optional, Type, TypeVar, Unpack, Union, Self
4
+ from queue import LifoQueue
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+ from pydantic.fields import FieldInfo, ComputedFieldInfo
7
+ from surrealdb import AsyncWsSurrealConnection, AsyncHttpSurrealConnection, RecordID
8
+
9
+ from .table import Table
10
+
11
+ T = TypeVar('T', bound='Base')
12
+
13
+ class Base(BaseModel):
14
+ id: RecordID | None
15
+
16
+ _registry: ClassVar[tuple[Table, ...]]
17
+ _tokenizers: ClassVar[set[str]]
18
+ _to_create: ClassVar[LifoQueue[Base]]
19
+ child_classes: ClassVar[dict[str, Type[Base]]]
20
+ _table: ClassVar[Table]
21
+ _discarded: ClassVar[bool]
22
+
23
+ @classmethod
24
+ def validate_record_id(cls, v: Any) -> Optional[RecordID]: ...
25
+
26
+ def _serialize(self, serializer: Any, info: Any) -> Any: ...
27
+
28
+ @classmethod
29
+ def validate_enums(cls, v: Any, info: Any) -> Any: ...
30
+
31
+ def __init_subclass__(cls, **kwargs: Unpack[ConfigDict]) -> None: ...
32
+
33
+ @classmethod
34
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: ...
35
+
36
+ @classmethod
37
+ def registered_tables(cls) -> set[str]: ...
38
+
39
+ @classmethod
40
+ def add_table(cls, table: Table, child_class: Type[Base]) -> None: ...
41
+
42
+ @classmethod
43
+ def registered_models(cls) -> list[Table]: ...
44
+
45
+ @classmethod
46
+ def generate_schema(cls) -> str: ...
47
+
48
+ @classmethod
49
+ def deserialize_record(cls, data: dict[str, Any]) -> Any: ...
50
+
51
+ @classmethod
52
+ def deserialize_response(cls, response: Any) -> Any: ...
53
+
54
+ def db_dump(self) -> dict[str, Any]: ...