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,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}"
|