surrealdb-orm 0.1.4__py3-none-any.whl → 0.5.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.

Potentially problematic release.


This version of surrealdb-orm might be problematic. Click here for more details.

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.1.dist-info/METADATA +465 -0
  45. surrealdb_orm-0.5.1.dist-info/RECORD +52 -0
  46. {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.1.dist-info}/WHEEL +1 -1
  47. surrealdb_orm-0.5.1.dist-info/entry_points.txt +2 -0
  48. {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.1.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,380 @@
1
+ """
2
+ Migration executor for applying migrations to the database.
3
+
4
+ This module handles:
5
+ - Tracking applied migrations in the database
6
+ - Applying pending migrations
7
+ - Rolling back migrations
8
+ - Executing data migrations (upgrade command)
9
+ """
10
+
11
+ import importlib.util
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from ..connection_manager import SurrealDBConnectionManager
17
+ from .migration import Migration, parse_migration_name
18
+ from .operations import DataMigration
19
+
20
+ if TYPE_CHECKING:
21
+ pass
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Table name for tracking migrations
26
+ MIGRATIONS_TABLE = "_surreal_orm_migrations"
27
+
28
+
29
+ class MigrationExecutor:
30
+ """
31
+ Executes migrations against the database.
32
+
33
+ Handles applying, rolling back, and tracking migrations.
34
+ """
35
+
36
+ def __init__(self, migrations_dir: Path | str):
37
+ """
38
+ Initialize the executor.
39
+
40
+ Args:
41
+ migrations_dir: Path to the migrations directory
42
+ """
43
+ self.migrations_dir = Path(migrations_dir)
44
+
45
+ async def ensure_migrations_table(self) -> None:
46
+ """Create the migrations tracking table if it doesn't exist."""
47
+ client = await SurrealDBConnectionManager.get_client()
48
+
49
+ await client.query(f"""
50
+ DEFINE TABLE IF NOT EXISTS {MIGRATIONS_TABLE} SCHEMAFULL;
51
+ DEFINE FIELD name ON {MIGRATIONS_TABLE} TYPE string;
52
+ DEFINE FIELD applied_at ON {MIGRATIONS_TABLE} TYPE datetime DEFAULT time::now();
53
+ DEFINE INDEX migration_name ON {MIGRATIONS_TABLE} FIELDS name UNIQUE;
54
+ """)
55
+
56
+ async def get_applied_migrations(self) -> list[str]:
57
+ """
58
+ Get list of already applied migration names.
59
+
60
+ Returns:
61
+ List of migration names that have been applied
62
+ """
63
+ await self.ensure_migrations_table()
64
+
65
+ client = await SurrealDBConnectionManager.get_client()
66
+ result = await client.query(f"SELECT name, applied_at FROM {MIGRATIONS_TABLE} ORDER BY applied_at;")
67
+
68
+ if result.is_empty: # type: ignore[attr-defined]
69
+ return []
70
+
71
+ return [r["name"] for r in result.all_records] # type: ignore[attr-defined]
72
+
73
+ def get_available_migrations(self) -> list[str]:
74
+ """
75
+ Get list of all migration files in the migrations directory.
76
+
77
+ Returns:
78
+ List of migration names sorted by number
79
+ """
80
+ if not self.migrations_dir.exists():
81
+ return []
82
+
83
+ migrations = []
84
+ for filepath in self.migrations_dir.glob("*.py"):
85
+ name = filepath.stem
86
+ if name.startswith("_"):
87
+ continue
88
+ try:
89
+ parse_migration_name(name)
90
+ migrations.append(name)
91
+ except ValueError:
92
+ continue
93
+
94
+ return sorted(migrations)
95
+
96
+ def get_pending_migrations(self, applied: list[str]) -> list[str]:
97
+ """
98
+ Get list of migrations that haven't been applied yet.
99
+
100
+ Args:
101
+ applied: List of already applied migration names
102
+
103
+ Returns:
104
+ List of pending migration names
105
+ """
106
+ available = self.get_available_migrations()
107
+ return [m for m in available if m not in applied]
108
+
109
+ def load_migration(self, name: str) -> Migration:
110
+ """
111
+ Load a migration from file.
112
+
113
+ Args:
114
+ name: Migration name (e.g., "0001_initial")
115
+
116
+ Returns:
117
+ Migration object
118
+
119
+ Raises:
120
+ FileNotFoundError: If migration file doesn't exist
121
+ ImportError: If migration file is invalid
122
+ """
123
+ filepath = self.migrations_dir / f"{name}.py"
124
+
125
+ if not filepath.exists():
126
+ raise FileNotFoundError(f"Migration file not found: {filepath}")
127
+
128
+ spec = importlib.util.spec_from_file_location(name, filepath)
129
+ if not spec or not spec.loader:
130
+ raise ImportError(f"Could not load migration: {name}")
131
+
132
+ module = importlib.util.module_from_spec(spec)
133
+ spec.loader.exec_module(module)
134
+
135
+ migration = getattr(module, "migration", None)
136
+ # Check by class name to handle different import paths (src.surreal_orm vs surreal_orm)
137
+ if migration is None or type(migration).__name__ != "Migration":
138
+ raise ImportError(f"Migration file must define 'migration' variable: {name}")
139
+
140
+ return migration # type: ignore[return-value, no-any-return]
141
+
142
+ def _sort_by_dependencies(self, migrations: list[Migration]) -> list[Migration]:
143
+ """
144
+ Topologically sort migrations by dependencies.
145
+
146
+ Args:
147
+ migrations: List of migrations to sort
148
+
149
+ Returns:
150
+ Sorted list of migrations
151
+ """
152
+ sorted_migrations: list[Migration] = []
153
+ remaining = migrations.copy()
154
+ seen: set[str] = set()
155
+ max_iterations = len(migrations) * 2
156
+
157
+ iteration = 0
158
+ while remaining and iteration < max_iterations:
159
+ iteration += 1
160
+ for m in remaining:
161
+ if all(dep in seen for dep in m.dependencies):
162
+ sorted_migrations.append(m)
163
+ seen.add(m.name)
164
+ remaining.remove(m)
165
+ break
166
+
167
+ if remaining:
168
+ raise ValueError(f"Circular dependency detected in migrations: {[m.name for m in remaining]}")
169
+
170
+ return sorted_migrations
171
+
172
+ async def migrate(
173
+ self,
174
+ target: str | None = None,
175
+ fake: bool = False,
176
+ schema_only: bool = True,
177
+ ) -> list[str]:
178
+ """
179
+ Apply pending migrations.
180
+
181
+ Args:
182
+ target: Optional target migration name. If None, apply all pending.
183
+ fake: If True, mark as applied without executing.
184
+ schema_only: If True, only apply schema operations (DDL).
185
+ If False, also apply data migrations (DML).
186
+
187
+ Returns:
188
+ List of applied migration names
189
+ """
190
+ await self.ensure_migrations_table()
191
+
192
+ applied = await self.get_applied_migrations()
193
+ pending_names = self.get_pending_migrations(applied)
194
+
195
+ if target:
196
+ # Filter to migrations up to and including target
197
+ pending_names = [n for n in pending_names if n <= target]
198
+
199
+ if not pending_names:
200
+ logger.info("No migrations to apply.")
201
+ return []
202
+
203
+ # Load and sort migrations
204
+ pending_migrations = [self.load_migration(name) for name in pending_names]
205
+ sorted_migrations = self._sort_by_dependencies(pending_migrations)
206
+
207
+ client = await SurrealDBConnectionManager.get_client()
208
+ applied_names: list[str] = []
209
+
210
+ for migration in sorted_migrations:
211
+ logger.info(f"Applying migration: {migration.name}")
212
+
213
+ if not fake:
214
+ # Get operations to execute
215
+ if schema_only:
216
+ operations = migration.schema_operations
217
+ else:
218
+ operations = migration.operations
219
+
220
+ # Execute each operation
221
+ for op in operations:
222
+ sql = op.forwards()
223
+ if sql:
224
+ logger.debug(f"Executing: {sql[:100]}...")
225
+ try:
226
+ await client.query(sql)
227
+ except Exception as e:
228
+ logger.error(f"Migration failed at {op.describe()}: {e}")
229
+ raise
230
+
231
+ # Handle async data migrations
232
+ if isinstance(op, DataMigration) and op.forwards_func:
233
+ await op.forwards_func()
234
+
235
+ # Record migration as applied
236
+ await client.query(
237
+ f"CREATE {MIGRATIONS_TABLE} SET name = $name;",
238
+ {"name": migration.name},
239
+ )
240
+
241
+ applied_names.append(migration.name)
242
+ logger.info(f"Applied: {migration.name}")
243
+
244
+ return applied_names
245
+
246
+ async def rollback(self, target: str) -> list[str]:
247
+ """
248
+ Rollback migrations to target.
249
+
250
+ Args:
251
+ target: Target migration name to rollback to.
252
+ Migrations after this will be rolled back.
253
+
254
+ Returns:
255
+ List of rolled back migration names
256
+ """
257
+ await self.ensure_migrations_table()
258
+
259
+ applied = await self.get_applied_migrations()
260
+
261
+ # Get migrations to rollback (in reverse order)
262
+ to_rollback = [name for name in reversed(applied) if name > target]
263
+
264
+ if not to_rollback:
265
+ logger.info("No migrations to rollback.")
266
+ return []
267
+
268
+ client = await SurrealDBConnectionManager.get_client()
269
+ rolled_back: list[str] = []
270
+
271
+ for name in to_rollback:
272
+ migration = self.load_migration(name)
273
+
274
+ if not migration.is_reversible:
275
+ raise ValueError(f"Migration {name} is not reversible")
276
+
277
+ logger.info(f"Rolling back: {name}")
278
+
279
+ # Execute backwards SQL
280
+ for sql in migration.backwards_sql():
281
+ if sql:
282
+ logger.debug(f"Executing: {sql[:100]}...")
283
+ await client.query(sql)
284
+
285
+ # Handle async data migrations backwards
286
+ for op in reversed(migration.operations):
287
+ if isinstance(op, DataMigration) and op.backwards_func:
288
+ await op.backwards_func()
289
+
290
+ # Remove migration record
291
+ await client.query(
292
+ f"DELETE {MIGRATIONS_TABLE} WHERE name = $name;",
293
+ {"name": name},
294
+ )
295
+
296
+ rolled_back.append(name)
297
+ logger.info(f"Rolled back: {name}")
298
+
299
+ return rolled_back
300
+
301
+ async def upgrade(self, target: str | None = None) -> list[str]:
302
+ """
303
+ Apply data migrations only.
304
+
305
+ This is separate from schema migrations to allow running
306
+ data transformations independently.
307
+
308
+ Args:
309
+ target: Optional target migration name
310
+
311
+ Returns:
312
+ List of migrations whose data operations were applied
313
+ """
314
+ await self.ensure_migrations_table()
315
+
316
+ applied = await self.get_applied_migrations()
317
+ applied_names: list[str] = []
318
+
319
+ client = await SurrealDBConnectionManager.get_client()
320
+
321
+ for name in applied:
322
+ if target and name > target:
323
+ break
324
+
325
+ migration = self.load_migration(name)
326
+
327
+ if not migration.has_data_migrations:
328
+ continue
329
+
330
+ logger.info(f"Running data migrations for: {name}")
331
+
332
+ for op in migration.data_operations:
333
+ sql = op.forwards()
334
+ if sql:
335
+ logger.debug(f"Executing: {sql[:100]}...")
336
+ await client.query(sql)
337
+
338
+ if isinstance(op, DataMigration) and op.forwards_func:
339
+ await op.forwards_func()
340
+
341
+ applied_names.append(name)
342
+
343
+ return applied_names
344
+
345
+ async def get_migration_status(self) -> dict[str, dict[str, bool | int]]:
346
+ """
347
+ Get status of all migrations.
348
+
349
+ Returns:
350
+ Dict mapping migration name to status info
351
+ """
352
+ applied = await self.get_applied_migrations()
353
+ available = self.get_available_migrations()
354
+
355
+ status = {}
356
+ for name in available:
357
+ is_applied = name in applied
358
+ migration = self.load_migration(name)
359
+ status[name] = {
360
+ "applied": is_applied,
361
+ "reversible": migration.is_reversible,
362
+ "has_data": migration.has_data_migrations,
363
+ "operations": len(migration.operations),
364
+ }
365
+
366
+ return status
367
+
368
+ async def show_sql(self, name: str) -> str:
369
+ """
370
+ Show the SQL that would be executed for a migration.
371
+
372
+ Args:
373
+ name: Migration name
374
+
375
+ Returns:
376
+ SQL statements as a string
377
+ """
378
+ migration = self.load_migration(name)
379
+ statements = migration.forwards_sql()
380
+ return "\n\n".join(statements)
@@ -0,0 +1,272 @@
1
+ """
2
+ Migration file generator.
3
+
4
+ This module generates Python migration files from a list of operations.
5
+ The generated files follow Django's migration format and can be edited
6
+ before being applied.
7
+ """
8
+
9
+ from dataclasses import MISSING
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from .migration import generate_migration_name
15
+
16
+ if TYPE_CHECKING:
17
+ from .operations import Operation
18
+
19
+
20
+ class MigrationGenerator:
21
+ """
22
+ Generates Python migration files from operations.
23
+
24
+ The generated files can be reviewed and edited before being applied
25
+ to the database.
26
+ """
27
+
28
+ def __init__(self, migrations_dir: Path | str):
29
+ """
30
+ Initialize the generator with a migrations directory.
31
+
32
+ Args:
33
+ migrations_dir: Path to the migrations directory
34
+ """
35
+ self.migrations_dir = Path(migrations_dir)
36
+
37
+ def ensure_directory(self) -> None:
38
+ """Create the migrations directory if it doesn't exist."""
39
+ self.migrations_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ # Create __init__.py if it doesn't exist
42
+ init_file = self.migrations_dir / "__init__.py"
43
+ if not init_file.exists():
44
+ init_file.write_text('"""Auto-generated migrations package."""\n')
45
+
46
+ def get_next_number(self) -> int:
47
+ """
48
+ Get the next migration number.
49
+
50
+ Returns:
51
+ Next available migration number
52
+ """
53
+ self.ensure_directory()
54
+
55
+ existing = list(self.migrations_dir.glob("*.py"))
56
+ max_num = 0
57
+
58
+ for filepath in existing:
59
+ name = filepath.stem
60
+ if name.startswith("_"):
61
+ continue
62
+ try:
63
+ parts = name.split("_", 1)
64
+ if parts[0].isdigit():
65
+ num = int(parts[0])
66
+ max_num = max(max_num, num)
67
+ except (ValueError, IndexError):
68
+ continue
69
+
70
+ return max_num + 1
71
+
72
+ def generate(
73
+ self,
74
+ name: str,
75
+ operations: list["Operation"],
76
+ dependencies: list[str] | None = None,
77
+ ) -> Path:
78
+ """
79
+ Generate a migration file.
80
+
81
+ Args:
82
+ name: Short descriptive name for the migration
83
+ operations: List of operations to include
84
+ dependencies: List of migration names this depends on
85
+
86
+ Returns:
87
+ Path to the generated migration file
88
+ """
89
+ self.ensure_directory()
90
+
91
+ # Determine migration number and full name
92
+ number = self.get_next_number()
93
+ full_name = generate_migration_name(number, name)
94
+
95
+ # Generate filename
96
+ filename = f"{full_name}.py"
97
+ filepath = self.migrations_dir / filename
98
+
99
+ # Generate content
100
+ content = self._render_migration(
101
+ name=full_name,
102
+ operations=operations,
103
+ dependencies=dependencies or [],
104
+ )
105
+
106
+ filepath.write_text(content)
107
+ return filepath
108
+
109
+ def _render_migration(
110
+ self,
111
+ name: str,
112
+ operations: list["Operation"],
113
+ dependencies: list[str],
114
+ ) -> str:
115
+ """
116
+ Render migration file content.
117
+
118
+ Args:
119
+ name: Migration name
120
+ operations: List of operations
121
+ dependencies: List of dependency names
122
+
123
+ Returns:
124
+ Python file content as string
125
+ """
126
+ lines = [
127
+ '"""',
128
+ f"Migration: {name}",
129
+ f"Generated: {datetime.now(timezone.utc).isoformat()}",
130
+ "",
131
+ "Auto-generated migration file. Review before applying.",
132
+ '"""',
133
+ "",
134
+ ]
135
+
136
+ # Collect required imports
137
+ op_types = set(type(op).__name__ for op in operations)
138
+ imports = sorted(op_types)
139
+
140
+ lines.append("from surreal_orm.migrations import Migration")
141
+ lines.append("from surreal_orm.migrations.operations import (")
142
+ for op_type in imports:
143
+ lines.append(f" {op_type},")
144
+ lines.append(")")
145
+ lines.append("")
146
+ lines.append("")
147
+
148
+ # Migration definition
149
+ lines.append("migration = Migration(")
150
+ lines.append(f' name="{name}",')
151
+ lines.append(f" dependencies={dependencies!r},")
152
+
153
+ if not operations:
154
+ lines.append(" operations=[],")
155
+ else:
156
+ lines.append(" operations=[")
157
+ for op in operations:
158
+ rendered = self._render_operation(op)
159
+ # Indent the operation
160
+ for line in rendered.split("\n"):
161
+ lines.append(f" {line}")
162
+ lines.append(" ],")
163
+
164
+ lines.append(")")
165
+ lines.append("")
166
+
167
+ return "\n".join(lines)
168
+
169
+ def _render_operation(self, op: "Operation") -> str:
170
+ """
171
+ Render a single operation as Python code.
172
+
173
+ Args:
174
+ op: Operation to render
175
+
176
+ Returns:
177
+ Python code string for the operation
178
+ """
179
+ op_type = type(op).__name__
180
+ args = []
181
+
182
+ # Get all dataclass fields
183
+ for field_name in op.__dataclass_fields__:
184
+ if field_name == "reversible":
185
+ continue
186
+
187
+ value = getattr(op, field_name)
188
+
189
+ # Skip None values for optional fields
190
+ if value is None:
191
+ continue
192
+
193
+ # Skip default values
194
+ field_info = op.__dataclass_fields__[field_name]
195
+ if field_info.default is not MISSING and value == field_info.default:
196
+ continue
197
+ if field_info.default_factory is not MISSING:
198
+ default_val = field_info.default_factory()
199
+ if value == default_val:
200
+ continue
201
+
202
+ # Format the value
203
+ args.append(f"{field_name}={self._format_value(value)}")
204
+
205
+ # Format as single line if short, multi-line if long
206
+ args_str = ", ".join(args)
207
+ if len(args_str) < 60:
208
+ return f"{op_type}({args_str}),"
209
+ else:
210
+ lines = [f"{op_type}("]
211
+ for arg in args:
212
+ lines.append(f" {arg},")
213
+ lines.append("),")
214
+ return "\n".join(lines)
215
+
216
+ def _format_value(self, value: object) -> str:
217
+ """
218
+ Format a value as Python literal.
219
+
220
+ Args:
221
+ value: Value to format
222
+
223
+ Returns:
224
+ Python literal string
225
+ """
226
+ if isinstance(value, str):
227
+ # Use repr for proper escaping
228
+ return repr(value)
229
+ elif isinstance(value, bool):
230
+ return str(value)
231
+ elif isinstance(value, (int, float)):
232
+ return str(value)
233
+ elif isinstance(value, list):
234
+ items = [self._format_value(v) for v in value]
235
+ return f"[{', '.join(items)}]"
236
+ elif isinstance(value, dict):
237
+ items = [f"{self._format_value(k)}: {self._format_value(v)}" for k, v in value.items()]
238
+ if len(items) == 0:
239
+ return "{}"
240
+ elif len(items) <= 2:
241
+ return "{" + ", ".join(items) + "}"
242
+ else:
243
+ # Multi-line dict
244
+ lines = ["{"]
245
+ for item in items:
246
+ lines.append(f" {item},")
247
+ lines.append("}")
248
+ return "\n".join(lines)
249
+ elif value is None:
250
+ return "None"
251
+ else:
252
+ return repr(value)
253
+
254
+
255
+ def generate_empty_migration(
256
+ migrations_dir: Path | str,
257
+ name: str,
258
+ dependencies: list[str] | None = None,
259
+ ) -> Path:
260
+ """
261
+ Generate an empty migration file for manual editing.
262
+
263
+ Args:
264
+ migrations_dir: Path to migrations directory
265
+ name: Migration name
266
+ dependencies: List of dependencies
267
+
268
+ Returns:
269
+ Path to generated file
270
+ """
271
+ generator = MigrationGenerator(migrations_dir)
272
+ return generator.generate(name=name, operations=[], dependencies=dependencies)