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,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()