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.
- surreal_orm/__init__.py +72 -3
- surreal_orm/aggregations.py +164 -0
- surreal_orm/auth/__init__.py +15 -0
- surreal_orm/auth/access.py +167 -0
- surreal_orm/auth/mixins.py +302 -0
- surreal_orm/cli/__init__.py +15 -0
- surreal_orm/cli/commands.py +369 -0
- surreal_orm/connection_manager.py +58 -18
- surreal_orm/fields/__init__.py +36 -0
- surreal_orm/fields/encrypted.py +166 -0
- surreal_orm/fields/relation.py +465 -0
- surreal_orm/migrations/__init__.py +51 -0
- surreal_orm/migrations/executor.py +380 -0
- surreal_orm/migrations/generator.py +272 -0
- surreal_orm/migrations/introspector.py +305 -0
- surreal_orm/migrations/migration.py +188 -0
- surreal_orm/migrations/operations.py +531 -0
- surreal_orm/migrations/state.py +406 -0
- surreal_orm/model_base.py +530 -44
- surreal_orm/query_set.py +609 -33
- surreal_orm/relations.py +645 -0
- surreal_orm/surreal_function.py +95 -0
- surreal_orm/surreal_ql.py +113 -0
- surreal_orm/types.py +86 -0
- surreal_sdk/README.md +79 -0
- surreal_sdk/__init__.py +151 -0
- surreal_sdk/connection/__init__.py +17 -0
- surreal_sdk/connection/base.py +516 -0
- surreal_sdk/connection/http.py +421 -0
- surreal_sdk/connection/pool.py +244 -0
- surreal_sdk/connection/websocket.py +519 -0
- surreal_sdk/exceptions.py +71 -0
- surreal_sdk/functions.py +607 -0
- surreal_sdk/protocol/__init__.py +13 -0
- surreal_sdk/protocol/rpc.py +218 -0
- surreal_sdk/py.typed +0 -0
- surreal_sdk/pyproject.toml +49 -0
- surreal_sdk/streaming/__init__.py +31 -0
- surreal_sdk/streaming/change_feed.py +278 -0
- surreal_sdk/streaming/live_query.py +265 -0
- surreal_sdk/streaming/live_select.py +369 -0
- surreal_sdk/transaction.py +386 -0
- surreal_sdk/types.py +346 -0
- surrealdb_orm-0.5.1.dist-info/METADATA +465 -0
- surrealdb_orm-0.5.1.dist-info/RECORD +52 -0
- {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.1.dist-info}/WHEEL +1 -1
- surrealdb_orm-0.5.1.dist-info/entry_points.txt +2 -0
- {surrealdb_orm-0.1.4.dist-info → surrealdb_orm-0.5.1.dist-info}/licenses/LICENSE +1 -1
- surrealdb_orm-0.1.4.dist-info/METADATA +0 -184
- 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)
|