surrealdb-orm 0.1.4__py3-none-any.whl → 0.5.0__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.
Files changed (50) hide show
  1. surreal_orm/__init__.py +72 -3
  2. surreal_orm/aggregations.py +164 -0
  3. surreal_orm/auth/__init__.py +15 -0
  4. surreal_orm/auth/access.py +167 -0
  5. surreal_orm/auth/mixins.py +302 -0
  6. surreal_orm/cli/__init__.py +15 -0
  7. surreal_orm/cli/commands.py +369 -0
  8. surreal_orm/connection_manager.py +58 -18
  9. surreal_orm/fields/__init__.py +36 -0
  10. surreal_orm/fields/encrypted.py +166 -0
  11. surreal_orm/fields/relation.py +465 -0
  12. surreal_orm/migrations/__init__.py +51 -0
  13. surreal_orm/migrations/executor.py +380 -0
  14. surreal_orm/migrations/generator.py +272 -0
  15. surreal_orm/migrations/introspector.py +305 -0
  16. surreal_orm/migrations/migration.py +188 -0
  17. surreal_orm/migrations/operations.py +531 -0
  18. surreal_orm/migrations/state.py +406 -0
  19. surreal_orm/model_base.py +530 -44
  20. surreal_orm/query_set.py +609 -33
  21. surreal_orm/relations.py +645 -0
  22. surreal_orm/surreal_function.py +95 -0
  23. surreal_orm/surreal_ql.py +113 -0
  24. surreal_orm/types.py +86 -0
  25. surreal_sdk/README.md +79 -0
  26. surreal_sdk/__init__.py +151 -0
  27. surreal_sdk/connection/__init__.py +17 -0
  28. surreal_sdk/connection/base.py +516 -0
  29. surreal_sdk/connection/http.py +421 -0
  30. surreal_sdk/connection/pool.py +244 -0
  31. surreal_sdk/connection/websocket.py +519 -0
  32. surreal_sdk/exceptions.py +71 -0
  33. surreal_sdk/functions.py +607 -0
  34. surreal_sdk/protocol/__init__.py +13 -0
  35. surreal_sdk/protocol/rpc.py +218 -0
  36. surreal_sdk/py.typed +0 -0
  37. surreal_sdk/pyproject.toml +49 -0
  38. surreal_sdk/streaming/__init__.py +31 -0
  39. surreal_sdk/streaming/change_feed.py +278 -0
  40. surreal_sdk/streaming/live_query.py +265 -0
  41. surreal_sdk/streaming/live_select.py +369 -0
  42. surreal_sdk/transaction.py +386 -0
  43. surreal_sdk/types.py +346 -0
  44. surrealdb_orm-0.5.0.dist-info/METADATA +465 -0
  45. surrealdb_orm-0.5.0.dist-info/RECORD +52 -0
  46. {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.0.dist-info}/WHEEL +1 -1
  47. surrealdb_orm-0.5.0.dist-info/entry_points.txt +2 -0
  48. {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.0.dist-info}/licenses/LICENSE +1 -1
  49. surrealdb_orm-0.1.4.dist-info/METADATA +0 -184
  50. surrealdb_orm-0.1.4.dist-info/RECORD +0 -12
@@ -0,0 +1,305 @@
1
+ """
2
+ Model introspection for migration generation.
3
+
4
+ This module extracts schema information from Pydantic models to build
5
+ a SchemaState that can be compared against the current database state.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING, Any, get_args, get_origin, get_type_hints
9
+ import types
10
+
11
+ from pydantic.fields import FieldInfo
12
+ from pydantic_core import PydanticUndefined
13
+
14
+ from ..fields.encrypted import is_encrypted_field
15
+ from ..types import PYTHON_TO_SURREAL_TYPE, FieldType, TableType
16
+ from .state import AccessState, FieldState, SchemaState, TableState
17
+
18
+ if TYPE_CHECKING:
19
+ from ..model_base import BaseSurrealModel
20
+
21
+
22
+ class ModelIntrospector:
23
+ """
24
+ Extracts schema information from Pydantic models.
25
+
26
+ This class inspects model definitions including:
27
+ - Table configuration (name, type, schema mode)
28
+ - Field definitions with types and constraints
29
+ - Encrypted field detection
30
+ - Access definition generation for USER tables
31
+ """
32
+
33
+ def __init__(self, models: list[type["BaseSurrealModel"]] | None = None):
34
+ """
35
+ Initialize the introspector with a list of models.
36
+
37
+ Args:
38
+ models: List of model classes to introspect. If None, uses
39
+ all registered models.
40
+ """
41
+ if models is None:
42
+ from ..model_base import get_registered_models
43
+
44
+ models = get_registered_models()
45
+ self.models = models
46
+
47
+ def introspect(self) -> SchemaState:
48
+ """
49
+ Build SchemaState from all registered models.
50
+
51
+ Returns:
52
+ SchemaState representing all model definitions
53
+ """
54
+ state = SchemaState()
55
+
56
+ for model in self.models:
57
+ table_state = self._introspect_model(model)
58
+ state.tables[table_state.name] = table_state
59
+
60
+ return state
61
+
62
+ def _introspect_model(self, model: type["BaseSurrealModel"]) -> TableState:
63
+ """
64
+ Extract table state from a single model.
65
+
66
+ Args:
67
+ model: The model class to introspect
68
+
69
+ Returns:
70
+ TableState representing the model's schema
71
+ """
72
+ # Get table configuration
73
+ table_name = model.get_table_name()
74
+ table_type = model.get_table_type()
75
+ schema_mode = model.get_schema_mode()
76
+ changefeed = model.get_changefeed()
77
+ permissions = model.get_permissions()
78
+
79
+ # Build table state
80
+ table_state = TableState(
81
+ name=table_name,
82
+ schema_mode=str(schema_mode),
83
+ table_type=str(table_type),
84
+ changefeed=changefeed,
85
+ permissions=permissions,
86
+ )
87
+
88
+ # Introspect fields
89
+ try:
90
+ type_hints = get_type_hints(model, include_extras=True)
91
+ except Exception:
92
+ # Fallback if type hints fail
93
+ type_hints = {}
94
+
95
+ for field_name, field_info in model.model_fields.items():
96
+ # Skip the id field - SurrealDB handles it automatically
97
+ if field_name == "id":
98
+ continue
99
+
100
+ field_type_hint = type_hints.get(field_name, field_info.annotation)
101
+ field_state = self._introspect_field(field_name, field_type_hint, field_info)
102
+ table_state.fields[field_name] = field_state
103
+
104
+ # Generate access definition for USER tables
105
+ if table_type == TableType.USER:
106
+ table_state.access = self._generate_access_state(model, table_name)
107
+
108
+ return table_state
109
+
110
+ def _introspect_field(
111
+ self,
112
+ name: str,
113
+ type_hint: Any,
114
+ field_info: FieldInfo,
115
+ ) -> FieldState:
116
+ """
117
+ Extract field state from type hint and field info.
118
+
119
+ Args:
120
+ name: Field name
121
+ type_hint: Type annotation for the field
122
+ field_info: Pydantic FieldInfo
123
+
124
+ Returns:
125
+ FieldState representing the field definition
126
+ """
127
+ # Check for Encrypted type
128
+ encrypted = is_encrypted_field(type_hint)
129
+
130
+ # Unwrap Encrypted type to get inner type
131
+ if encrypted:
132
+ args = get_args(type_hint)
133
+ if args:
134
+ type_hint = args[0]
135
+ else:
136
+ type_hint = str
137
+
138
+ # Handle Optional/Union types
139
+ nullable = False
140
+ origin = get_origin(type_hint)
141
+
142
+ if origin is types.UnionType or origin is type(None):
143
+ # Handle X | None syntax
144
+ args = get_args(type_hint)
145
+ non_none_args = [a for a in args if a is not type(None)]
146
+ if type(None) in args:
147
+ nullable = True
148
+ if non_none_args:
149
+ type_hint = non_none_args[0]
150
+ else:
151
+ type_hint = str
152
+
153
+ # Map Python type to SurrealDB type
154
+ surreal_type = self._map_type(type_hint)
155
+
156
+ # Get default value
157
+ default = None
158
+ if field_info.default is not None and field_info.default is not ... and field_info.default is not PydanticUndefined:
159
+ default = field_info.default
160
+ elif field_info.default_factory is not None:
161
+ # Can't serialize factory, skip default
162
+ pass
163
+
164
+ # Check for flexible type
165
+ flexible = False
166
+ if hasattr(field_info, "json_schema_extra") and field_info.json_schema_extra:
167
+ if isinstance(field_info.json_schema_extra, dict):
168
+ flexible_val = field_info.json_schema_extra.get("flexible", False)
169
+ flexible = flexible_val is True
170
+
171
+ return FieldState(
172
+ name=name,
173
+ field_type=surreal_type,
174
+ nullable=nullable,
175
+ default=default,
176
+ encrypted=encrypted,
177
+ flexible=flexible,
178
+ )
179
+
180
+ def _map_type(self, python_type: Any) -> str:
181
+ """
182
+ Map a Python type to a SurrealDB type string.
183
+
184
+ Args:
185
+ python_type: Python type annotation
186
+
187
+ Returns:
188
+ SurrealDB type string
189
+ """
190
+ origin = get_origin(python_type)
191
+
192
+ # Handle generic types
193
+ if origin is list:
194
+ args = get_args(python_type)
195
+ if args:
196
+ inner_type = self._map_type(args[0])
197
+ return f"array<{inner_type}>"
198
+ return "array"
199
+
200
+ if origin is dict:
201
+ return "object"
202
+
203
+ if origin is set:
204
+ args = get_args(python_type)
205
+ if args:
206
+ inner_type = self._map_type(args[0])
207
+ return f"set<{inner_type}>"
208
+ return "set"
209
+
210
+ # Handle basic types
211
+ if python_type in PYTHON_TO_SURREAL_TYPE:
212
+ return PYTHON_TO_SURREAL_TYPE[python_type].value
213
+
214
+ # Handle string type names
215
+ if isinstance(python_type, type):
216
+ type_name = python_type.__name__.lower()
217
+ if type_name == "str":
218
+ return FieldType.STRING.value
219
+ elif type_name == "int":
220
+ return FieldType.INT.value
221
+ elif type_name == "float":
222
+ return FieldType.FLOAT.value
223
+ elif type_name == "bool":
224
+ return FieldType.BOOL.value
225
+ elif type_name == "datetime":
226
+ return FieldType.DATETIME.value
227
+ elif type_name == "uuid":
228
+ return FieldType.UUID.value
229
+ elif type_name == "bytes":
230
+ return FieldType.BYTES.value
231
+
232
+ # Fallback
233
+ return FieldType.ANY.value
234
+
235
+ def _generate_access_state(
236
+ self,
237
+ model: type["BaseSurrealModel"],
238
+ table_name: str,
239
+ ) -> AccessState:
240
+ """
241
+ Generate access state for a USER type table.
242
+
243
+ Args:
244
+ model: The user model class
245
+ table_name: Name of the table
246
+
247
+ Returns:
248
+ AccessState for authentication
249
+ """
250
+ identifier_field = model.get_identifier_field()
251
+ password_field = model.get_password_field()
252
+
253
+ # Build signup fields
254
+ signup_fields: dict[str, str] = {}
255
+
256
+ # Get all model fields
257
+ for field_name in model.model_fields:
258
+ if field_name == "id":
259
+ continue
260
+
261
+ if field_name == password_field:
262
+ # Password gets encrypted
263
+ signup_fields[field_name] = f"crypto::argon2::generate(${field_name})"
264
+ else:
265
+ # Regular fields are passed through
266
+ signup_fields[field_name] = f"${field_name}"
267
+
268
+ # Add default timestamp if not in fields
269
+ if "created_at" not in signup_fields and "created_at" not in model.model_fields:
270
+ signup_fields["created_at"] = "time::now()"
271
+
272
+ # Build signin WHERE clause
273
+ signin_where = (
274
+ f"{identifier_field} = ${identifier_field} AND crypto::argon2::compare({password_field}, ${password_field})"
275
+ )
276
+
277
+ # Get duration settings from config
278
+ config = getattr(model, "model_config", {})
279
+ token_duration = config.get("token_duration", "15m")
280
+ session_duration = config.get("session_duration", "12h")
281
+
282
+ return AccessState(
283
+ name=f"{table_name.lower()}_auth",
284
+ table=table_name,
285
+ signup_fields=signup_fields,
286
+ signin_where=signin_where,
287
+ duration_token=token_duration,
288
+ duration_session=session_duration,
289
+ )
290
+
291
+
292
+ def introspect_models(
293
+ models: list[type["BaseSurrealModel"]] | None = None,
294
+ ) -> SchemaState:
295
+ """
296
+ Convenience function to introspect models.
297
+
298
+ Args:
299
+ models: List of model classes, or None to use all registered models
300
+
301
+ Returns:
302
+ SchemaState representing the models
303
+ """
304
+ introspector = ModelIntrospector(models)
305
+ return introspector.introspect()
@@ -0,0 +1,188 @@
1
+ """
2
+ Migration class and utilities for SurrealDB schema migrations.
3
+
4
+ A Migration represents a set of operations to be applied to the database
5
+ in a specific order, with optional dependencies on other migrations.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .operations import Operation
14
+
15
+
16
+ @dataclass
17
+ class Migration:
18
+ """
19
+ Represents a single migration containing a list of operations.
20
+
21
+ Migrations are stored as Python files and can be applied forwards
22
+ (to update the schema) or backwards (to rollback changes).
23
+
24
+ Attributes:
25
+ name: Unique identifier for the migration (e.g., "0001_initial")
26
+ dependencies: List of migration names this depends on
27
+ operations: List of operations to apply
28
+ created_at: Timestamp when the migration was created
29
+
30
+ Example:
31
+ migration = Migration(
32
+ name="0001_initial",
33
+ dependencies=[],
34
+ operations=[
35
+ CreateTable(name="User", schema_mode="SCHEMAFULL"),
36
+ AddField(table="User", name="email", field_type="string"),
37
+ ],
38
+ )
39
+ """
40
+
41
+ name: str
42
+ dependencies: list[str] = field(default_factory=list)
43
+ operations: list["Operation"] = field(default_factory=list)
44
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
45
+
46
+ def forwards_sql(self) -> list[str]:
47
+ """
48
+ Generate all forward migration SQL statements.
49
+
50
+ Returns:
51
+ List of SurrealQL statements to apply the migration
52
+ """
53
+ statements = []
54
+ for op in self.operations:
55
+ sql = op.forwards()
56
+ if sql:
57
+ statements.append(sql)
58
+ return statements
59
+
60
+ def backwards_sql(self) -> list[str]:
61
+ """
62
+ Generate all rollback SQL statements.
63
+
64
+ Operations are reversed in order for proper rollback.
65
+
66
+ Returns:
67
+ List of SurrealQL statements to rollback the migration
68
+ """
69
+ statements = []
70
+ for op in reversed(self.operations):
71
+ if op.reversible:
72
+ sql = op.backwards()
73
+ if sql:
74
+ statements.append(sql)
75
+ return statements
76
+
77
+ @property
78
+ def is_reversible(self) -> bool:
79
+ """
80
+ Check if all operations in this migration are reversible.
81
+
82
+ Returns:
83
+ True if the entire migration can be rolled back
84
+ """
85
+ return all(op.reversible for op in self.operations)
86
+
87
+ @property
88
+ def has_data_migrations(self) -> bool:
89
+ """
90
+ Check if this migration contains data migrations.
91
+
92
+ Data migrations modify existing records rather than schema.
93
+
94
+ Returns:
95
+ True if the migration contains DataMigration operations
96
+ """
97
+ from .operations import DataMigration
98
+
99
+ return any(isinstance(op, DataMigration) for op in self.operations)
100
+
101
+ @property
102
+ def schema_operations(self) -> list["Operation"]:
103
+ """
104
+ Get only schema-modifying operations (DDL).
105
+
106
+ Returns:
107
+ List of operations that modify schema (not data)
108
+ """
109
+ from .operations import DataMigration
110
+
111
+ return [op for op in self.operations if not isinstance(op, DataMigration)]
112
+
113
+ @property
114
+ def data_operations(self) -> list["Operation"]:
115
+ """
116
+ Get only data-modifying operations (DML).
117
+
118
+ Returns:
119
+ List of DataMigration operations
120
+ """
121
+ from .operations import DataMigration
122
+
123
+ return [op for op in self.operations if isinstance(op, DataMigration)]
124
+
125
+ def describe(self) -> str:
126
+ """
127
+ Get a human-readable description of this migration.
128
+
129
+ Returns:
130
+ Multi-line string describing all operations
131
+ """
132
+ lines = [f"Migration: {self.name}"]
133
+ if self.dependencies:
134
+ lines.append(f"Dependencies: {', '.join(self.dependencies)}")
135
+ lines.append(f"Operations ({len(self.operations)}):")
136
+ for i, op in enumerate(self.operations, 1):
137
+ lines.append(f" {i}. {op.describe()}")
138
+ return "\n".join(lines)
139
+
140
+ def __repr__(self) -> str:
141
+ return f"Migration(name={self.name!r}, operations={len(self.operations)})"
142
+
143
+
144
+ def parse_migration_name(filename: str) -> tuple[int, str]:
145
+ """
146
+ Parse a migration filename into number and name.
147
+
148
+ Args:
149
+ filename: Migration filename (e.g., "0001_initial.py")
150
+
151
+ Returns:
152
+ Tuple of (migration_number, migration_name)
153
+
154
+ Raises:
155
+ ValueError: If filename doesn't match expected format
156
+ """
157
+ # Remove .py extension if present
158
+ if filename.endswith(".py"):
159
+ filename = filename[:-3]
160
+
161
+ parts = filename.split("_", 1)
162
+ if len(parts) != 2:
163
+ raise ValueError(f"Invalid migration filename: {filename}")
164
+
165
+ try:
166
+ number = int(parts[0])
167
+ except ValueError:
168
+ raise ValueError(f"Invalid migration number in: {filename}")
169
+
170
+ return number, parts[1]
171
+
172
+
173
+ def generate_migration_name(number: int, name: str) -> str:
174
+ """
175
+ Generate a migration filename from number and name.
176
+
177
+ Args:
178
+ number: Migration sequence number
179
+ name: Descriptive name for the migration
180
+
181
+ Returns:
182
+ Formatted filename (e.g., "0001_initial")
183
+ """
184
+ # Sanitize name: lowercase, replace spaces with underscores
185
+ safe_name = name.lower().replace(" ", "_").replace("-", "_")
186
+ # Remove any non-alphanumeric characters except underscores
187
+ safe_name = "".join(c for c in safe_name if c.isalnum() or c == "_")
188
+ return f"{number:04d}_{safe_name}"