surrealdb-orm 0.1.3__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.
- surreal_orm/__init__.py +78 -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 +594 -135
- surreal_orm/py.typed +0 -0
- surreal_orm/query_set.py +609 -34
- 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.0.dist-info/METADATA +465 -0
- surrealdb_orm-0.5.0.dist-info/RECORD +52 -0
- {surrealdb_orm-0.1.3.dist-info → surrealdb_orm-0.5.0.dist-info}/WHEEL +1 -1
- surrealdb_orm-0.5.0.dist-info/entry_points.txt +2 -0
- {surrealdb_orm-0.1.3.dist-info → surrealdb_orm-0.5.0.dist-info}/licenses/LICENSE +1 -1
- surrealdb_orm-0.1.3.dist-info/METADATA +0 -184
- surrealdb_orm-0.1.3.dist-info/RECORD +0 -11
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication mixins for SurrealDB ORM models.
|
|
3
|
+
|
|
4
|
+
Provides signup/signin methods for USER type models using
|
|
5
|
+
SurrealDB's native JWT authentication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticatedUserMixin:
|
|
15
|
+
"""
|
|
16
|
+
Mixin providing authentication methods for User models.
|
|
17
|
+
|
|
18
|
+
Add this mixin to your USER type models to enable signup/signin
|
|
19
|
+
functionality using SurrealDB's DEFINE ACCESS authentication.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
class User(AuthenticatedUserMixin, BaseSurrealModel):
|
|
23
|
+
model_config = SurrealConfigDict(
|
|
24
|
+
table_type=TableType.USER,
|
|
25
|
+
identifier_field="email",
|
|
26
|
+
password_field="password",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
id: str | None = None
|
|
30
|
+
email: str
|
|
31
|
+
password: Encrypted
|
|
32
|
+
name: str
|
|
33
|
+
|
|
34
|
+
# Create a new user
|
|
35
|
+
user = await User.signup(email="test@example.com", password="secret", name="Test")
|
|
36
|
+
|
|
37
|
+
# Authenticate existing user
|
|
38
|
+
user, token = await User.signin(email="test@example.com", password="secret")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
async def signup(
|
|
43
|
+
cls,
|
|
44
|
+
**credentials: Any,
|
|
45
|
+
) -> Self:
|
|
46
|
+
"""
|
|
47
|
+
Create a new user via DEFINE ACCESS signup.
|
|
48
|
+
|
|
49
|
+
This method uses SurrealDB's native signup functionality, which:
|
|
50
|
+
1. Validates the credentials against the ACCESS definition
|
|
51
|
+
2. Creates the user record with encrypted password
|
|
52
|
+
3. Returns a JWT token (stored internally)
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
**credentials: User credentials matching the model fields
|
|
56
|
+
(e.g., email, password, name)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Created user instance
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
SurrealDbError: If signup fails (e.g., duplicate email)
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
user = await User.signup(
|
|
66
|
+
email="user@example.com",
|
|
67
|
+
password="secure_password",
|
|
68
|
+
name="John Doe"
|
|
69
|
+
)
|
|
70
|
+
"""
|
|
71
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
72
|
+
from ..model_base import SurrealDbError
|
|
73
|
+
|
|
74
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
75
|
+
|
|
76
|
+
# Get access configuration from model
|
|
77
|
+
config = getattr(cls, "model_config", {})
|
|
78
|
+
table_name = config.get("table_name") or cls.__name__
|
|
79
|
+
access_name = f"{table_name.lower()}_auth"
|
|
80
|
+
|
|
81
|
+
# Get connection info
|
|
82
|
+
namespace = SurrealDBConnectionManager.get_namespace()
|
|
83
|
+
database = SurrealDBConnectionManager.get_database()
|
|
84
|
+
|
|
85
|
+
if not namespace or not database:
|
|
86
|
+
raise SurrealDbError("Namespace and database must be set for authentication")
|
|
87
|
+
|
|
88
|
+
# Perform signup via SDK
|
|
89
|
+
response = await client.signup(
|
|
90
|
+
namespace=namespace,
|
|
91
|
+
database=database,
|
|
92
|
+
access=access_name,
|
|
93
|
+
**credentials,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if not response.success:
|
|
97
|
+
raise SurrealDbError(f"Signup failed: {response.raw}")
|
|
98
|
+
|
|
99
|
+
# Fetch the created user
|
|
100
|
+
identifier_field = config.get("identifier_field", "email")
|
|
101
|
+
identifier_value = credentials.get(identifier_field)
|
|
102
|
+
|
|
103
|
+
if not identifier_value:
|
|
104
|
+
raise SurrealDbError(f"Missing required field: {identifier_field}")
|
|
105
|
+
|
|
106
|
+
result = await client.query(
|
|
107
|
+
f"SELECT * FROM {table_name} WHERE {identifier_field} = $identifier",
|
|
108
|
+
{"identifier": identifier_value},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if result.is_empty: # type: ignore[attr-defined]
|
|
112
|
+
raise cls.DoesNotExist("User not found after signup") # type: ignore[attr-defined]
|
|
113
|
+
|
|
114
|
+
return cls.from_db(result.first) # type: ignore
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
async def signin(
|
|
118
|
+
cls,
|
|
119
|
+
**credentials: Any,
|
|
120
|
+
) -> tuple[Self, str]:
|
|
121
|
+
"""
|
|
122
|
+
Authenticate a user via DEFINE ACCESS signin.
|
|
123
|
+
|
|
124
|
+
This method uses SurrealDB's native signin functionality, which:
|
|
125
|
+
1. Validates credentials against the ACCESS definition
|
|
126
|
+
2. Compares the password using the configured algorithm
|
|
127
|
+
3. Returns a JWT token for subsequent authenticated requests
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
**credentials: User credentials (identifier and password)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Tuple of (user instance, JWT token)
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
SurrealDbError: If signin fails (invalid credentials)
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
user, token = await User.signin(
|
|
140
|
+
email="user@example.com",
|
|
141
|
+
password="secure_password"
|
|
142
|
+
)
|
|
143
|
+
# Use token for authenticated requests
|
|
144
|
+
"""
|
|
145
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
146
|
+
from ..model_base import SurrealDbError
|
|
147
|
+
|
|
148
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
149
|
+
|
|
150
|
+
# Get access configuration from model
|
|
151
|
+
config = getattr(cls, "model_config", {})
|
|
152
|
+
table_name = config.get("table_name") or cls.__name__
|
|
153
|
+
access_name = f"{table_name.lower()}_auth"
|
|
154
|
+
|
|
155
|
+
# Get connection info
|
|
156
|
+
namespace = SurrealDBConnectionManager.get_namespace()
|
|
157
|
+
database = SurrealDBConnectionManager.get_database()
|
|
158
|
+
|
|
159
|
+
if not namespace or not database:
|
|
160
|
+
raise SurrealDbError("Namespace and database must be set for authentication")
|
|
161
|
+
|
|
162
|
+
# Perform signin via SDK
|
|
163
|
+
response = await client.signin(
|
|
164
|
+
namespace=namespace,
|
|
165
|
+
database=database,
|
|
166
|
+
access=access_name,
|
|
167
|
+
**credentials,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not response.success or not response.token:
|
|
171
|
+
raise SurrealDbError(f"Signin failed: {response.raw}")
|
|
172
|
+
|
|
173
|
+
# Fetch user info
|
|
174
|
+
identifier_field = config.get("identifier_field", "email")
|
|
175
|
+
identifier_value = credentials.get(identifier_field)
|
|
176
|
+
|
|
177
|
+
if not identifier_value:
|
|
178
|
+
raise SurrealDbError(f"Missing required field: {identifier_field}")
|
|
179
|
+
|
|
180
|
+
result = await client.query(
|
|
181
|
+
f"SELECT * FROM {table_name} WHERE {identifier_field} = $identifier",
|
|
182
|
+
{"identifier": identifier_value},
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if result.is_empty: # type: ignore[attr-defined]
|
|
186
|
+
raise cls.DoesNotExist("User not found after signin") # type: ignore[attr-defined]
|
|
187
|
+
|
|
188
|
+
user = cls.from_db(result.first) # type: ignore
|
|
189
|
+
return user, response.token
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
async def authenticate_token(
|
|
193
|
+
cls,
|
|
194
|
+
token: str,
|
|
195
|
+
) -> Self | None:
|
|
196
|
+
"""
|
|
197
|
+
Authenticate using an existing JWT token.
|
|
198
|
+
|
|
199
|
+
Use this method to validate and authenticate a user from
|
|
200
|
+
a previously obtained JWT token.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
token: JWT token from previous signin
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
User instance if token is valid, None otherwise
|
|
207
|
+
|
|
208
|
+
Example:
|
|
209
|
+
user = await User.authenticate_token(stored_token)
|
|
210
|
+
if user:
|
|
211
|
+
print(f"Authenticated as {user.email}")
|
|
212
|
+
"""
|
|
213
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
214
|
+
|
|
215
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
# Authenticate with the token
|
|
219
|
+
response = await client.authenticate(token) # type: ignore[attr-defined]
|
|
220
|
+
|
|
221
|
+
if not response.success:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# Get current user info using $auth variable
|
|
225
|
+
config = getattr(cls, "model_config", {})
|
|
226
|
+
table_name = config.get("table_name") or cls.__name__
|
|
227
|
+
|
|
228
|
+
result = await client.query(f"SELECT * FROM {table_name} WHERE id = $auth.id")
|
|
229
|
+
|
|
230
|
+
if result.is_empty: # type: ignore[attr-defined]
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
return cls.from_db(result.first) # type: ignore
|
|
234
|
+
|
|
235
|
+
except Exception:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
@classmethod
|
|
239
|
+
async def change_password(
|
|
240
|
+
cls,
|
|
241
|
+
identifier_value: str,
|
|
242
|
+
old_password: str,
|
|
243
|
+
new_password: str,
|
|
244
|
+
) -> bool:
|
|
245
|
+
"""
|
|
246
|
+
Change a user's password.
|
|
247
|
+
|
|
248
|
+
Validates the old password before setting the new one.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
identifier_value: Value of the identifier field (e.g., email)
|
|
252
|
+
old_password: Current password for verification
|
|
253
|
+
new_password: New password to set
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if password was changed successfully
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
SurrealDbError: If old password is incorrect
|
|
260
|
+
"""
|
|
261
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
262
|
+
from ..model_base import SurrealDbError
|
|
263
|
+
|
|
264
|
+
# First verify the old password by attempting signin
|
|
265
|
+
config = getattr(cls, "model_config", {})
|
|
266
|
+
identifier_field = config.get("identifier_field", "email")
|
|
267
|
+
password_field = config.get("password_field", "password")
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
await cls.signin(**{identifier_field: identifier_value, password_field: old_password}) # type: ignore[attr-defined]
|
|
271
|
+
except SurrealDbError:
|
|
272
|
+
raise SurrealDbError("Invalid current password") from None
|
|
273
|
+
|
|
274
|
+
# Update the password (the access definition will handle encryption)
|
|
275
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
276
|
+
table_name = config.get("table_name") or cls.__name__
|
|
277
|
+
|
|
278
|
+
# Get encryption algorithm
|
|
279
|
+
algorithm = config.get("encryption_algorithm", "argon2")
|
|
280
|
+
|
|
281
|
+
await client.query(
|
|
282
|
+
f"""
|
|
283
|
+
UPDATE {table_name}
|
|
284
|
+
SET {password_field} = crypto::{algorithm}::generate($new_password)
|
|
285
|
+
WHERE {identifier_field} = $identifier
|
|
286
|
+
""",
|
|
287
|
+
{"identifier": identifier_value, "new_password": new_password},
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def get_access_name(cls) -> str:
|
|
294
|
+
"""
|
|
295
|
+
Get the access definition name for this model.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Access name (e.g., "user_auth")
|
|
299
|
+
"""
|
|
300
|
+
config = getattr(cls, "model_config", {})
|
|
301
|
+
table_name = config.get("table_name") or cls.__name__
|
|
302
|
+
return f"{table_name.lower()}_auth"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SurrealDB ORM Command Line Interface.
|
|
3
|
+
|
|
4
|
+
Provides Django-style migration commands:
|
|
5
|
+
- makemigrations: Generate migration files from model changes
|
|
6
|
+
- migrate: Apply schema migrations to the database
|
|
7
|
+
- upgrade: Apply data migrations to transform records
|
|
8
|
+
- rollback: Revert migrations to a specific point
|
|
9
|
+
- status: Show migration status
|
|
10
|
+
- sqlmigrate: Show SQL for a migration without executing
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .commands import cli
|
|
14
|
+
|
|
15
|
+
__all__ = ["cli"]
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI commands for SurrealDB ORM migrations.
|
|
3
|
+
|
|
4
|
+
Uses click for command-line argument parsing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import click
|
|
14
|
+
except ImportError:
|
|
15
|
+
click = None # type: ignore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def require_click() -> None:
|
|
19
|
+
"""Raise error if click is not installed."""
|
|
20
|
+
if click is None:
|
|
21
|
+
print("Error: click is required for CLI. Install with: pip install click")
|
|
22
|
+
sys.exit(1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_async(coro: Any) -> Any:
|
|
26
|
+
"""Run an async coroutine synchronously."""
|
|
27
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Only define CLI if click is available
|
|
31
|
+
if click is not None:
|
|
32
|
+
|
|
33
|
+
@click.group()
|
|
34
|
+
@click.option(
|
|
35
|
+
"--migrations-dir",
|
|
36
|
+
"-m",
|
|
37
|
+
default="migrations",
|
|
38
|
+
help="Migrations directory (default: migrations)",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--url",
|
|
42
|
+
"-u",
|
|
43
|
+
envvar="SURREAL_URL",
|
|
44
|
+
default="http://localhost:8000",
|
|
45
|
+
help="SurrealDB URL",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--namespace",
|
|
49
|
+
"-n",
|
|
50
|
+
envvar="SURREAL_NAMESPACE",
|
|
51
|
+
help="SurrealDB namespace",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--database",
|
|
55
|
+
"-d",
|
|
56
|
+
envvar="SURREAL_DATABASE",
|
|
57
|
+
help="SurrealDB database",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--user",
|
|
61
|
+
envvar="SURREAL_USER",
|
|
62
|
+
default="root",
|
|
63
|
+
help="SurrealDB user",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--password",
|
|
67
|
+
envvar="SURREAL_PASSWORD",
|
|
68
|
+
default="root",
|
|
69
|
+
help="SurrealDB password",
|
|
70
|
+
)
|
|
71
|
+
@click.pass_context
|
|
72
|
+
def cli(
|
|
73
|
+
ctx: click.Context,
|
|
74
|
+
migrations_dir: str,
|
|
75
|
+
url: str,
|
|
76
|
+
namespace: str | None,
|
|
77
|
+
database: str | None,
|
|
78
|
+
user: str,
|
|
79
|
+
password: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""SurrealDB ORM migration management tool."""
|
|
82
|
+
ctx.ensure_object(dict)
|
|
83
|
+
ctx.obj["migrations_dir"] = Path(migrations_dir)
|
|
84
|
+
ctx.obj["url"] = url
|
|
85
|
+
ctx.obj["namespace"] = namespace
|
|
86
|
+
ctx.obj["database"] = database
|
|
87
|
+
ctx.obj["user"] = user
|
|
88
|
+
ctx.obj["password"] = password
|
|
89
|
+
|
|
90
|
+
@cli.command()
|
|
91
|
+
@click.option("--name", "-n", required=True, help="Migration name")
|
|
92
|
+
@click.option("--empty", is_flag=True, help="Create empty migration for manual editing")
|
|
93
|
+
@click.option("--models", "-m", multiple=True, help="Specific model modules to include")
|
|
94
|
+
@click.pass_context
|
|
95
|
+
def makemigrations(
|
|
96
|
+
ctx: click.Context,
|
|
97
|
+
name: str,
|
|
98
|
+
empty: bool,
|
|
99
|
+
models: tuple[str, ...],
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Generate migration files from model changes."""
|
|
102
|
+
from ..migrations.generator import MigrationGenerator, generate_empty_migration
|
|
103
|
+
from ..migrations.introspector import introspect_models
|
|
104
|
+
from ..migrations.state import SchemaState
|
|
105
|
+
|
|
106
|
+
migrations_dir = ctx.obj["migrations_dir"]
|
|
107
|
+
|
|
108
|
+
if empty:
|
|
109
|
+
# Create empty migration
|
|
110
|
+
filepath = generate_empty_migration(migrations_dir, name)
|
|
111
|
+
click.echo(f"Created empty migration: {filepath}")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Import models if specified
|
|
115
|
+
if models:
|
|
116
|
+
for module_path in models:
|
|
117
|
+
try:
|
|
118
|
+
__import__(module_path)
|
|
119
|
+
except ImportError as e:
|
|
120
|
+
click.echo(f"Error importing {module_path}: {e}", err=True)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
|
|
123
|
+
# Introspect models to get desired state
|
|
124
|
+
desired_state = introspect_models()
|
|
125
|
+
|
|
126
|
+
if not desired_state.tables:
|
|
127
|
+
click.echo("No models found. Register models by importing them.")
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
|
|
130
|
+
# For now, assume current state is empty (first migration)
|
|
131
|
+
# In a full implementation, we'd load current state from database
|
|
132
|
+
current_state = SchemaState()
|
|
133
|
+
|
|
134
|
+
# Compute differences
|
|
135
|
+
operations = current_state.diff(desired_state)
|
|
136
|
+
|
|
137
|
+
if not operations:
|
|
138
|
+
click.echo("No changes detected.")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# Generate migration file
|
|
142
|
+
generator = MigrationGenerator(migrations_dir)
|
|
143
|
+
filepath = generator.generate(name=name, operations=operations)
|
|
144
|
+
|
|
145
|
+
click.echo(f"Created migration: {filepath}")
|
|
146
|
+
click.echo(f"Operations: {len(operations)}")
|
|
147
|
+
for op in operations:
|
|
148
|
+
click.echo(f" - {op.describe()}")
|
|
149
|
+
|
|
150
|
+
@cli.command()
|
|
151
|
+
@click.option("--target", "-t", help="Target migration name")
|
|
152
|
+
@click.option("--fake", is_flag=True, help="Mark as applied without executing")
|
|
153
|
+
@click.pass_context
|
|
154
|
+
def migrate(ctx: click.Context, target: str | None, fake: bool) -> None:
|
|
155
|
+
"""Apply pending schema migrations."""
|
|
156
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
157
|
+
from ..migrations.executor import MigrationExecutor
|
|
158
|
+
|
|
159
|
+
async def run() -> list[str]:
|
|
160
|
+
# Setup connection
|
|
161
|
+
SurrealDBConnectionManager.set_connection(
|
|
162
|
+
url=ctx.obj["url"],
|
|
163
|
+
user=ctx.obj["user"],
|
|
164
|
+
password=ctx.obj["password"],
|
|
165
|
+
namespace=ctx.obj["namespace"],
|
|
166
|
+
database=ctx.obj["database"],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
executor = MigrationExecutor(ctx.obj["migrations_dir"])
|
|
170
|
+
return await executor.migrate(target=target, fake=fake, schema_only=True)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
applied = run_async(run())
|
|
174
|
+
if applied:
|
|
175
|
+
click.echo(f"Applied {len(applied)} migration(s):")
|
|
176
|
+
for name in applied:
|
|
177
|
+
click.echo(f" - {name}")
|
|
178
|
+
else:
|
|
179
|
+
click.echo("No migrations to apply.")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
click.echo(f"Migration failed: {e}", err=True)
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
@cli.command()
|
|
185
|
+
@click.option("--target", "-t", help="Target migration name")
|
|
186
|
+
@click.pass_context
|
|
187
|
+
def upgrade(ctx: click.Context, target: str | None) -> None:
|
|
188
|
+
"""Apply data migrations to transform records."""
|
|
189
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
190
|
+
from ..migrations.executor import MigrationExecutor
|
|
191
|
+
|
|
192
|
+
async def run() -> list[str]:
|
|
193
|
+
SurrealDBConnectionManager.set_connection(
|
|
194
|
+
url=ctx.obj["url"],
|
|
195
|
+
user=ctx.obj["user"],
|
|
196
|
+
password=ctx.obj["password"],
|
|
197
|
+
namespace=ctx.obj["namespace"],
|
|
198
|
+
database=ctx.obj["database"],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
executor = MigrationExecutor(ctx.obj["migrations_dir"])
|
|
202
|
+
return await executor.upgrade(target=target)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
applied = run_async(run())
|
|
206
|
+
if applied:
|
|
207
|
+
click.echo(f"Applied data migrations for {len(applied)} migration(s):")
|
|
208
|
+
for name in applied:
|
|
209
|
+
click.echo(f" - {name}")
|
|
210
|
+
else:
|
|
211
|
+
click.echo("No data migrations to apply.")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
click.echo(f"Upgrade failed: {e}", err=True)
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
|
|
216
|
+
@cli.command()
|
|
217
|
+
@click.argument("target")
|
|
218
|
+
@click.pass_context
|
|
219
|
+
def rollback(ctx: click.Context, target: str) -> None:
|
|
220
|
+
"""Rollback migrations to TARGET."""
|
|
221
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
222
|
+
from ..migrations.executor import MigrationExecutor
|
|
223
|
+
|
|
224
|
+
async def run() -> list[str]:
|
|
225
|
+
SurrealDBConnectionManager.set_connection(
|
|
226
|
+
url=ctx.obj["url"],
|
|
227
|
+
user=ctx.obj["user"],
|
|
228
|
+
password=ctx.obj["password"],
|
|
229
|
+
namespace=ctx.obj["namespace"],
|
|
230
|
+
database=ctx.obj["database"],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
executor = MigrationExecutor(ctx.obj["migrations_dir"])
|
|
234
|
+
return await executor.rollback(target=target)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
rolled_back = run_async(run())
|
|
238
|
+
if rolled_back:
|
|
239
|
+
click.echo(f"Rolled back {len(rolled_back)} migration(s):")
|
|
240
|
+
for name in rolled_back:
|
|
241
|
+
click.echo(f" - {name}")
|
|
242
|
+
else:
|
|
243
|
+
click.echo("No migrations to rollback.")
|
|
244
|
+
except Exception as e:
|
|
245
|
+
click.echo(f"Rollback failed: {e}", err=True)
|
|
246
|
+
sys.exit(1)
|
|
247
|
+
|
|
248
|
+
@cli.command()
|
|
249
|
+
@click.pass_context
|
|
250
|
+
def status(ctx: click.Context) -> None:
|
|
251
|
+
"""Show migration status."""
|
|
252
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
253
|
+
from ..migrations.executor import MigrationExecutor
|
|
254
|
+
|
|
255
|
+
async def run() -> dict[str, dict[str, Any]]:
|
|
256
|
+
SurrealDBConnectionManager.set_connection(
|
|
257
|
+
url=ctx.obj["url"],
|
|
258
|
+
user=ctx.obj["user"],
|
|
259
|
+
password=ctx.obj["password"],
|
|
260
|
+
namespace=ctx.obj["namespace"],
|
|
261
|
+
database=ctx.obj["database"],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
executor = MigrationExecutor(ctx.obj["migrations_dir"])
|
|
265
|
+
return await executor.get_migration_status()
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
status_info = run_async(run())
|
|
269
|
+
|
|
270
|
+
if not status_info:
|
|
271
|
+
click.echo("No migrations found.")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
click.echo("Migration status:")
|
|
275
|
+
click.echo("-" * 60)
|
|
276
|
+
|
|
277
|
+
for name, info in status_info.items():
|
|
278
|
+
status_char = "[X]" if info["applied"] else "[ ]"
|
|
279
|
+
reversible = "R" if info["reversible"] else "-"
|
|
280
|
+
has_data = "D" if info["has_data"] else "-"
|
|
281
|
+
ops = info["operations"]
|
|
282
|
+
click.echo(f"{status_char} {name} ({ops} ops) [{reversible}{has_data}]")
|
|
283
|
+
|
|
284
|
+
click.echo("-" * 60)
|
|
285
|
+
click.echo("Legend: [X]=applied, R=reversible, D=has data migrations")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
click.echo(f"Status failed: {e}", err=True)
|
|
288
|
+
sys.exit(1)
|
|
289
|
+
|
|
290
|
+
@cli.command()
|
|
291
|
+
@click.argument("migration")
|
|
292
|
+
@click.pass_context
|
|
293
|
+
def sqlmigrate(ctx: click.Context, migration: str) -> None:
|
|
294
|
+
"""Show SQL for MIGRATION without executing."""
|
|
295
|
+
from ..migrations.executor import MigrationExecutor
|
|
296
|
+
|
|
297
|
+
executor = MigrationExecutor(ctx.obj["migrations_dir"])
|
|
298
|
+
|
|
299
|
+
async def run() -> str:
|
|
300
|
+
return await executor.show_sql(migration)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
sql = run_async(run())
|
|
304
|
+
click.echo(sql)
|
|
305
|
+
except FileNotFoundError:
|
|
306
|
+
click.echo(f"Migration not found: {migration}", err=True)
|
|
307
|
+
sys.exit(1)
|
|
308
|
+
except Exception as e:
|
|
309
|
+
click.echo(f"Error: {e}", err=True)
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
|
|
312
|
+
@cli.command()
|
|
313
|
+
@click.pass_context
|
|
314
|
+
def shell(ctx: click.Context) -> None:
|
|
315
|
+
"""Start an interactive SurrealDB shell."""
|
|
316
|
+
from ..connection_manager import SurrealDBConnectionManager
|
|
317
|
+
|
|
318
|
+
async def setup() -> None:
|
|
319
|
+
SurrealDBConnectionManager.set_connection(
|
|
320
|
+
url=ctx.obj["url"],
|
|
321
|
+
user=ctx.obj["user"],
|
|
322
|
+
password=ctx.obj["password"],
|
|
323
|
+
namespace=ctx.obj["namespace"],
|
|
324
|
+
database=ctx.obj["database"],
|
|
325
|
+
)
|
|
326
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
327
|
+
click.echo(f"Connected to {ctx.obj['url']}")
|
|
328
|
+
click.echo(f"Namespace: {ctx.obj['namespace']}, Database: {ctx.obj['database']}")
|
|
329
|
+
click.echo("Type 'exit' or 'quit' to exit. Enter SurrealQL queries:")
|
|
330
|
+
click.echo("-" * 60)
|
|
331
|
+
|
|
332
|
+
while True:
|
|
333
|
+
try:
|
|
334
|
+
query = click.prompt("surreal", prompt_suffix="> ")
|
|
335
|
+
if query.lower() in ("exit", "quit"):
|
|
336
|
+
break
|
|
337
|
+
if not query.strip():
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
result = await client.query(query)
|
|
341
|
+
if result.is_empty: # type: ignore[attr-defined]
|
|
342
|
+
click.echo("(empty result)")
|
|
343
|
+
else:
|
|
344
|
+
for record in result.all_records: # type: ignore[attr-defined]
|
|
345
|
+
click.echo(record)
|
|
346
|
+
except KeyboardInterrupt:
|
|
347
|
+
break
|
|
348
|
+
except Exception as e:
|
|
349
|
+
click.echo(f"Error: {e}")
|
|
350
|
+
|
|
351
|
+
click.echo("Goodbye!")
|
|
352
|
+
|
|
353
|
+
run_async(setup())
|
|
354
|
+
|
|
355
|
+
else:
|
|
356
|
+
# Placeholder CLI if click is not installed
|
|
357
|
+
def cli() -> None: # type: ignore
|
|
358
|
+
"""CLI placeholder when click is not installed."""
|
|
359
|
+
require_click()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def main() -> None:
|
|
363
|
+
"""Entry point for the CLI."""
|
|
364
|
+
require_click()
|
|
365
|
+
cli()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
main()
|