oxyde-postgres 0.1.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.
Files changed (66) hide show
  1. oxyde/__init__.py +151 -0
  2. oxyde/_msgpack.py +3 -0
  3. oxyde/cli/__init__.py +7 -0
  4. oxyde/cli/app.py +107 -0
  5. oxyde/cli/codegen.py +66 -0
  6. oxyde/cli/config.py +84 -0
  7. oxyde/cli/migrations.py +536 -0
  8. oxyde/codegen/__init__.py +15 -0
  9. oxyde/codegen/stub_generator.py +1229 -0
  10. oxyde/core/__init__.py +90 -0
  11. oxyde/core/column_types.py +367 -0
  12. oxyde/core/ir.py +380 -0
  13. oxyde/core/types.py +69 -0
  14. oxyde/core/wrapper.py +242 -0
  15. oxyde/db/__init__.py +230 -0
  16. oxyde/db/pool.py +305 -0
  17. oxyde/db/registry.py +113 -0
  18. oxyde/db/schema.py +51 -0
  19. oxyde/db/transaction.py +392 -0
  20. oxyde/exceptions.py +89 -0
  21. oxyde/migrations/__init__.py +49 -0
  22. oxyde/migrations/config.py +187 -0
  23. oxyde/migrations/context.py +598 -0
  24. oxyde/migrations/executor.py +456 -0
  25. oxyde/migrations/extract.py +316 -0
  26. oxyde/migrations/generator.py +534 -0
  27. oxyde/migrations/replay.py +367 -0
  28. oxyde/migrations/squash.py +109 -0
  29. oxyde/migrations/tracker.py +162 -0
  30. oxyde/migrations/utils.py +145 -0
  31. oxyde/models/__init__.py +83 -0
  32. oxyde/models/base.py +871 -0
  33. oxyde/models/decorators.py +111 -0
  34. oxyde/models/field.py +204 -0
  35. oxyde/models/lookups.py +667 -0
  36. oxyde/models/metadata.py +174 -0
  37. oxyde/models/registry.py +170 -0
  38. oxyde/models/serializers.py +144 -0
  39. oxyde/models/utils.py +116 -0
  40. oxyde/py.typed +0 -0
  41. oxyde/queries/__init__.py +79 -0
  42. oxyde/queries/aggregates.py +145 -0
  43. oxyde/queries/base.py +249 -0
  44. oxyde/queries/conditions.py +92 -0
  45. oxyde/queries/expressions.py +171 -0
  46. oxyde/queries/insert.py +124 -0
  47. oxyde/queries/joins.py +66 -0
  48. oxyde/queries/manager.py +409 -0
  49. oxyde/queries/mixins/__init__.py +68 -0
  50. oxyde/queries/mixins/aggregation.py +335 -0
  51. oxyde/queries/mixins/debug.py +154 -0
  52. oxyde/queries/mixins/execution.py +701 -0
  53. oxyde/queries/mixins/filtering.py +144 -0
  54. oxyde/queries/mixins/joining.py +160 -0
  55. oxyde/queries/mixins/mutation.py +508 -0
  56. oxyde/queries/mixins/pagination.py +145 -0
  57. oxyde/queries/q.py +250 -0
  58. oxyde/queries/raw.py +114 -0
  59. oxyde/queries/select.py +345 -0
  60. oxyde/queries/typed.py +213 -0
  61. oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
  62. oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
  63. oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
  64. oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
  65. oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
  66. oxyde_postgres-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,536 @@
1
+ """Migration commands (makemigrations, migrate, showmigrations, sqlmigrate)."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from oxyde.cli.app import (
10
+ app,
11
+ ensure_migrations_dir,
12
+ init_databases,
13
+ load_config_or_exit,
14
+ require_databases,
15
+ )
16
+ from oxyde.migrations.config import import_models
17
+
18
+
19
+ @app.command()
20
+ def makemigrations(
21
+ name: str | None = typer.Option(None, help="Migration name"),
22
+ dry_run: bool = typer.Option(
23
+ False, help="Show what would be created without actually creating"
24
+ ),
25
+ ) -> None:
26
+ """
27
+ Create migration files by comparing current models with replayed migrations.
28
+
29
+ Scans all Model subclasses, replays existing migrations,
30
+ computes diff, and generates a new migration file if changes detected.
31
+ """
32
+ from oxyde.core import migration_compute_diff
33
+ from oxyde.migrations import (
34
+ extract_current_schema,
35
+ generate_migration_file,
36
+ replay_migrations,
37
+ )
38
+
39
+ # Load config
40
+ config = load_config_or_exit()
41
+
42
+ typer.echo("📝 Creating migrations...")
43
+ typer.echo()
44
+
45
+ # Import models
46
+ typer.echo("0️⃣ Loading models...")
47
+ imported = import_models(config.models)
48
+ if imported == 0:
49
+ typer.secho(" ❌ No modules imported", fg=typer.colors.RED)
50
+ raise typer.Exit(1)
51
+ typer.echo(f" ✅ Imported {imported} module(s)")
52
+
53
+ # Step 1: Extract current schema from models
54
+ typer.echo()
55
+ typer.echo("1️⃣ Extracting schema from models...")
56
+ try:
57
+ current_schema = extract_current_schema(dialect=config.dialect)
58
+ table_count = len(current_schema["tables"])
59
+ tables = ", ".join(current_schema["tables"].keys())
60
+ if table_count > 0:
61
+ typer.echo(f" ✅ Found {table_count} table(s): {tables}")
62
+ else:
63
+ typer.secho(" ⚠️ No tables found", fg=typer.colors.YELLOW)
64
+ typer.echo(" Make sure your models have 'class Meta: is_table = True'")
65
+ except Exception as e:
66
+ typer.secho(f" ❌ Error extracting schema: {e}", fg=typer.colors.RED)
67
+ raise typer.Exit(1)
68
+
69
+ # Step 2: Replay existing migrations
70
+ typer.echo()
71
+ typer.echo("2️⃣ Replaying existing migrations...")
72
+ migrations_path = ensure_migrations_dir(config.migrations_dir, dry_run=dry_run)
73
+
74
+ if not migrations_path.exists():
75
+ old_schema = {"version": 1, "tables": {}}
76
+ else:
77
+ try:
78
+ old_schema = replay_migrations(config.migrations_dir)
79
+ migration_count = len(list(migrations_path.glob("[0-9]*.py")))
80
+ typer.echo(f" ✅ Replayed {migration_count} migration(s)")
81
+ except Exception as e:
82
+ typer.secho(f" ❌ Error replaying migrations: {e}", fg=typer.colors.RED)
83
+ typer.echo(" Fix the broken migration(s) before running makemigrations.")
84
+ raise typer.Exit(1)
85
+
86
+ # Step 3: Compute diff
87
+ typer.echo()
88
+ typer.echo("3️⃣ Computing diff...")
89
+ try:
90
+ operations_json = migration_compute_diff(
91
+ json.dumps(old_schema), json.dumps(current_schema)
92
+ )
93
+ operations = json.loads(operations_json)
94
+
95
+ if not operations:
96
+ typer.echo()
97
+ typer.secho(" ✨ No changes detected", fg=typer.colors.GREEN)
98
+ return
99
+
100
+ typer.echo(f" ✅ Found {len(operations)} operation(s):")
101
+ for op in operations:
102
+ op_type = op.get("type", "unknown")
103
+ if op_type == "create_table":
104
+ typer.echo(f" - Create table: {op['table']['name']}")
105
+ elif op_type == "drop_table":
106
+ typer.echo(f" - Drop table: {op['name']}")
107
+ elif op_type == "add_column":
108
+ typer.echo(f" - Add column: {op['table']}.{op['field']['name']}")
109
+ elif op_type == "drop_column":
110
+ typer.echo(f" - Drop column: {op['table']}.{op['field']}")
111
+ elif op_type == "alter_enum_type":
112
+ typer.secho(
113
+ f" - Manual enum change: {op['name']} "
114
+ f"{op['old_values']} -> {op['new_values']}",
115
+ fg=typer.colors.YELLOW,
116
+ )
117
+ else:
118
+ typer.echo(f" - {op_type}")
119
+
120
+ if any(op.get("type") == "alter_enum_type" for op in operations):
121
+ typer.secho(
122
+ " ⚠️ One or more enum types changed in a way that requires "
123
+ "manual SQL. The migration file will include a ctx.require_manual(...) "
124
+ "guard; replace it with ctx.execute(...) and keep ctx.alter_enum_type(...).",
125
+ fg=typer.colors.YELLOW,
126
+ )
127
+
128
+ except Exception as e:
129
+ typer.secho(f" ❌ Error computing diff: {e}", fg=typer.colors.RED)
130
+ raise typer.Exit(1)
131
+
132
+ # Step 4: Generate migration file
133
+ typer.echo()
134
+ if dry_run:
135
+ typer.secho(" [DRY RUN] Would create migration file", fg=typer.colors.YELLOW)
136
+ typer.echo(f" Migration name: {name or 'auto'}")
137
+ typer.echo(f" Operations: {len(operations)}")
138
+ else:
139
+ typer.echo("4️⃣ Generating migration file...")
140
+ try:
141
+ filepath = generate_migration_file(
142
+ operations,
143
+ migrations_dir=config.migrations_dir,
144
+ name=name,
145
+ )
146
+ typer.echo()
147
+ typer.secho(f" ✅ Created: {filepath}", fg=typer.colors.GREEN, bold=True)
148
+ except Exception as e:
149
+ typer.secho(f" ❌ Error generating migration: {e}", fg=typer.colors.RED)
150
+ raise typer.Exit(1)
151
+
152
+ # Step 5: Generate type stubs
153
+ typer.echo()
154
+ typer.echo("5️⃣ Generating type stubs...")
155
+ try:
156
+ from oxyde.codegen import generate_stubs_for_models, write_stubs
157
+ from oxyde.models.registry import registered_tables
158
+
159
+ models = list(registered_tables().values())
160
+ if models:
161
+ stub_mapping = generate_stubs_for_models(models)
162
+ write_stubs(stub_mapping)
163
+ typer.secho(
164
+ f" ✅ Generated {len(stub_mapping)} stub file(s)",
165
+ fg=typer.colors.GREEN,
166
+ )
167
+ else:
168
+ typer.echo(" ⚠️ No models to generate stubs for")
169
+ except Exception as e:
170
+ typer.secho(
171
+ f" ⚠️ Warning: Could not generate stubs: {e}",
172
+ fg=typer.colors.YELLOW,
173
+ )
174
+ # Don't fail migration on stub generation errors
175
+
176
+
177
+ @app.command()
178
+ def migrate(
179
+ target: str | None = typer.Argument(None, help="Target migration name (e.g. 0001)"),
180
+ fake: bool = typer.Option(
181
+ False, help="Mark migrations as applied without running SQL"
182
+ ),
183
+ db_alias: str = typer.Option("default", help="Database connection alias"),
184
+ ) -> None:
185
+ """
186
+ Apply all pending migrations.
187
+
188
+ Runs all migrations that haven't been applied yet.
189
+ Specify migration name to migrate to a specific version.
190
+ """
191
+ from oxyde.migrations import (
192
+ apply_migrations,
193
+ get_applied_migrations,
194
+ get_pending_migrations,
195
+ )
196
+
197
+ # Load config
198
+ config = load_config_or_exit()
199
+ require_databases(config, db_alias)
200
+
201
+ typer.echo("⏳ Applying migrations...")
202
+ typer.echo()
203
+
204
+ if fake:
205
+ typer.secho(
206
+ "⚠️ [FAKE MODE] Marking migrations as applied without executing SQL",
207
+ fg=typer.colors.YELLOW,
208
+ )
209
+ typer.echo()
210
+
211
+ async def run_migrate() -> tuple[str | None, list]:
212
+ from oxyde.migrations import get_migration_files, rollback_migrations
213
+
214
+ # Initialize database connection
215
+ await init_databases({db_alias: config.databases[db_alias]})
216
+
217
+ # Get current state
218
+ applied = await get_applied_migrations(db_alias)
219
+ all_migrations = get_migration_files(config.migrations_dir)
220
+
221
+ # If target specified, check if we need to rollback
222
+ if target:
223
+ # Special case: "zero" means rollback all migrations
224
+ if target.lower() == "zero":
225
+ if not applied:
226
+ typer.secho("✨ No migrations to roll back", fg=typer.colors.GREEN)
227
+ return "rollback", []
228
+
229
+ typer.echo(f"Rolling back all {len(applied)} migration(s)...")
230
+ typer.echo()
231
+
232
+ rolled_back = await rollback_migrations(
233
+ steps=len(applied),
234
+ migrations_dir=config.migrations_dir,
235
+ db_alias=db_alias,
236
+ fake=fake,
237
+ )
238
+ return "rollback", rolled_back
239
+
240
+ # Find target migration index
241
+ target_idx = -1
242
+ for i, m in enumerate(all_migrations):
243
+ if m.stem == target or m.stem.startswith(target):
244
+ target_idx = i
245
+ break
246
+
247
+ if target_idx == -1:
248
+ typer.secho(f"❌ Migration '{target}' not found", fg=typer.colors.RED)
249
+ return None, []
250
+
251
+ # Find current position (last applied migration)
252
+ current_idx = -1
253
+ for i, m in enumerate(all_migrations):
254
+ if m.stem in applied:
255
+ current_idx = i
256
+
257
+ # If target is before current position, rollback
258
+ if target_idx < current_idx:
259
+ steps = current_idx - target_idx
260
+ typer.echo(f"Rolling back {steps} migration(s) to reach {target}...")
261
+ typer.echo()
262
+
263
+ rolled_back = await rollback_migrations(
264
+ steps=steps,
265
+ migrations_dir=config.migrations_dir,
266
+ db_alias=db_alias,
267
+ fake=fake,
268
+ )
269
+ return "rollback", rolled_back
270
+
271
+ # Forward migration
272
+ pending = get_pending_migrations(config.migrations_dir, applied)
273
+
274
+ if not pending:
275
+ typer.secho("✨ No pending migrations", fg=typer.colors.GREEN)
276
+ return "apply", []
277
+
278
+ # Filter pending if target specified
279
+ if target:
280
+ filtered = []
281
+ for m in pending:
282
+ filtered.append(m)
283
+ if m.stem == target or m.stem.startswith(target):
284
+ break
285
+ pending = filtered
286
+
287
+ # Show what will be applied
288
+ typer.echo(f"Found {len(pending)} pending migration(s):")
289
+ for migration_path in pending:
290
+ typer.echo(f" - {migration_path.stem}")
291
+
292
+ typer.echo()
293
+
294
+ # Apply migrations
295
+ if target:
296
+ typer.echo(f"Migrating to: {target}")
297
+ else:
298
+ typer.echo("Migrating to latest...")
299
+
300
+ applied_migrations = await apply_migrations(
301
+ migrations_dir=config.migrations_dir,
302
+ db_alias=db_alias,
303
+ target=target,
304
+ fake=fake,
305
+ )
306
+
307
+ return "apply", applied_migrations
308
+
309
+ try:
310
+ result = asyncio.run(run_migrate())
311
+ if result is None or result[0] is None:
312
+ raise typer.Exit(1)
313
+
314
+ action, migrations = result
315
+
316
+ if migrations:
317
+ typer.echo()
318
+ if action == "rollback":
319
+ typer.secho(
320
+ f"✅ Rolled back {len(migrations)} migration(s)",
321
+ fg=typer.colors.GREEN,
322
+ bold=True,
323
+ )
324
+ else:
325
+ typer.secho(
326
+ f"✅ Applied {len(migrations)} migration(s)",
327
+ fg=typer.colors.GREEN,
328
+ bold=True,
329
+ )
330
+ for name in migrations:
331
+ typer.echo(f" - {name}")
332
+
333
+ except Exception as e:
334
+ typer.secho(f"❌ Error applying migrations: {e}", fg=typer.colors.RED)
335
+ raise typer.Exit(1)
336
+
337
+
338
+ @app.command()
339
+ def showmigrations(
340
+ db_alias: str = typer.Option("default", help="Database connection alias"),
341
+ ) -> None:
342
+ """
343
+ Show list of all migrations with their status (applied/pending).
344
+ """
345
+ from oxyde.migrations import get_applied_migrations, get_migration_files
346
+
347
+ # Load config
348
+ config = load_config_or_exit()
349
+ require_databases(config, db_alias)
350
+
351
+ typer.echo("📋 Migrations status:")
352
+ typer.echo()
353
+
354
+ async def run_show() -> set[str]:
355
+ # Initialize database connection
356
+ await init_databases({db_alias: config.databases[db_alias]})
357
+
358
+ # Get applied migrations
359
+ applied = await get_applied_migrations(db_alias)
360
+ return set(applied)
361
+
362
+ try:
363
+ applied_set = asyncio.run(run_show())
364
+
365
+ # Get all migration files
366
+ all_migrations = get_migration_files(config.migrations_dir)
367
+
368
+ if not all_migrations:
369
+ typer.secho("No migrations found", fg=typer.colors.YELLOW)
370
+ return
371
+
372
+ # Show status for each migration
373
+ for migration_path in all_migrations:
374
+ name = migration_path.stem
375
+ if name in applied_set:
376
+ typer.secho(f" [✓] {name}", fg=typer.colors.GREEN)
377
+ else:
378
+ typer.echo(f" [ ] {name}")
379
+
380
+ typer.echo()
381
+ typer.echo(f"Total: {len(all_migrations)} migration(s)")
382
+ typer.echo(f"Applied: {len(applied_set)}")
383
+ typer.echo(f"Pending: {len(all_migrations) - len(applied_set)}")
384
+
385
+ except Exception as e:
386
+ typer.secho(f"❌ Error reading migrations: {e}", fg=typer.colors.RED)
387
+ raise typer.Exit(1)
388
+
389
+
390
+ @app.command()
391
+ def sqlmigrate(
392
+ name: str,
393
+ ) -> None:
394
+ """
395
+ Print the SQL for a specific migration.
396
+
397
+ Args:
398
+ name: Name of the migration file (e.g., 0001_initial)
399
+ """
400
+ from oxyde.core import migration_to_sql
401
+ from oxyde.migrations.context import MigrationContext
402
+ from oxyde.migrations.utils import load_migration_module
403
+
404
+ # Load config
405
+ config = load_config_or_exit()
406
+
407
+ typer.echo(f"📝 SQL for migration: {name}")
408
+ typer.echo()
409
+
410
+ try:
411
+ # Find migration file
412
+ migration_path = Path(config.migrations_dir) / f"{name}.py"
413
+ if not migration_path.exists():
414
+ typer.secho(f"❌ Migration not found: {name}", fg=typer.colors.RED)
415
+ raise typer.Exit(1)
416
+
417
+ # Import migration module
418
+ module = load_migration_module(migration_path)
419
+
420
+ if module is None or not hasattr(module, "upgrade"):
421
+ typer.secho("❌ Migration missing upgrade() function", fg=typer.colors.RED)
422
+ raise typer.Exit(1)
423
+
424
+ # Create context in collect mode to get operations
425
+ ctx = MigrationContext(mode="collect", dialect=config.dialect)
426
+ module.upgrade(ctx)
427
+
428
+ operations = ctx.get_collected_operations()
429
+
430
+ if not operations:
431
+ typer.echo("-- No operations (empty migration)")
432
+ return
433
+
434
+ # Convert operations to SQL
435
+ operations_json = json.dumps(operations)
436
+ sql_statements = migration_to_sql(operations_json, config.dialect)
437
+
438
+ # Print SQL
439
+ typer.secho(f"-- Generated SQL ({config.dialect}):", fg=typer.colors.CYAN)
440
+ typer.echo()
441
+ for sql in sql_statements:
442
+ typer.echo(sql)
443
+ typer.echo()
444
+
445
+ # Manual ops emit no SQL — surface them so the preview is not empty.
446
+ for op in operations:
447
+ if op.get("type") == "alter_enum_type":
448
+ typer.secho(
449
+ f"-- manual migration required: enum {op['name']} "
450
+ f"{op['old_values']} -> {op['new_values']} "
451
+ "(no automatic SQL; the migration file pairs this with "
452
+ "ctx.require_manual(...))",
453
+ fg=typer.colors.YELLOW,
454
+ )
455
+ typer.echo()
456
+
457
+ except Exception as e:
458
+ typer.secho(f"❌ Error generating SQL: {e}", fg=typer.colors.RED)
459
+ raise typer.Exit(1)
460
+
461
+
462
+ # ── oxyde migrations <subcommand> ────────────────────────────────────────
463
+
464
+ migrations_app = typer.Typer(help="Migration maintenance commands")
465
+ app.add_typer(migrations_app, name="migrations")
466
+
467
+
468
+ @migrations_app.command("squash")
469
+ def squash(
470
+ name: str = typer.Option(
471
+ "squashed", help="Name suffix for the new initial migration"
472
+ ),
473
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
474
+ ) -> None:
475
+ """
476
+ Squash the whole migration history into a single initial migration.
477
+
478
+ Replays all migration files in memory, computes the final schema and
479
+ replaces the history with one 0001_<name>.py in the current format.
480
+ Old files are deleted (version control is your backup). Manual SQL
481
+ (ctx.execute) is NOT carried over — affected files are listed.
482
+
483
+ On already-deployed databases run `oxyde migrate --fake` afterwards to
484
+ record the new initial migration without executing it.
485
+ """
486
+ from oxyde.migrations.squash import squash_migrations
487
+
488
+ config = load_config_or_exit()
489
+ migrations_path = Path(config.migrations_dir)
490
+ files = sorted(migrations_path.glob("[0-9]*.py"))
491
+
492
+ if not files:
493
+ typer.secho(
494
+ "Nothing to squash: no migration files found.", fg=typer.colors.YELLOW
495
+ )
496
+ raise typer.Exit(0)
497
+
498
+ typer.echo(f"Will squash {len(files)} migration file(s) into one:")
499
+ for f in files:
500
+ typer.echo(f" • {f.name}")
501
+ typer.echo()
502
+
503
+ if not yes and not typer.confirm(
504
+ "Delete these files and write a new 0001 migration?"
505
+ ):
506
+ raise typer.Exit(0)
507
+
508
+ try:
509
+ result = squash_migrations(config.migrations_dir, name=name)
510
+ except Exception as e:
511
+ typer.secho(f"❌ Squash failed: {e}", fg=typer.colors.RED)
512
+ raise typer.Exit(1) from None
513
+
514
+ assert result.new_file is not None
515
+ typer.secho(
516
+ f"✅ Created {result.new_file.name} ({result.table_count} table(s)), "
517
+ f"deleted {len(result.deleted_files)} file(s).",
518
+ fg=typer.colors.GREEN,
519
+ )
520
+
521
+ if result.raw_sql_files:
522
+ typer.echo()
523
+ typer.secho(
524
+ "⚠️ These files contained manual SQL (ctx.execute) that was NOT "
525
+ "carried over — move it manually if still needed:",
526
+ fg=typer.colors.YELLOW,
527
+ )
528
+ for fname in result.raw_sql_files:
529
+ typer.echo(f" • {fname}")
530
+
531
+ typer.echo()
532
+ typer.echo("Next steps:")
533
+ typer.echo(" • fresh databases: oxyde migrate")
534
+ typer.echo(
535
+ " • deployed databases: oxyde migrate --fake (records 0001 without executing)"
536
+ )
@@ -0,0 +1,15 @@
1
+ """Code generation utilities for Oxyde ORM."""
2
+
3
+ from oxyde.codegen.stub_generator import (
4
+ generate_model_stub,
5
+ generate_stub_for_file,
6
+ generate_stubs_for_models,
7
+ write_stubs,
8
+ )
9
+
10
+ __all__ = [
11
+ "generate_model_stub",
12
+ "generate_stub_for_file",
13
+ "generate_stubs_for_models",
14
+ "write_stubs",
15
+ ]