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,598 @@
1
+ """Migration context for upgrade/downgrade operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from oxyde._msgpack import msgpack
9
+ from oxyde.core import (
10
+ begin_transaction,
11
+ commit_transaction,
12
+ execute_in_transaction,
13
+ migration_to_sql,
14
+ rollback_transaction,
15
+ )
16
+ from oxyde.core.ir import build_raw_sql_ir
17
+ from oxyde.db.pool import _msgpack_encoder
18
+
19
+ if TYPE_CHECKING:
20
+ from oxyde.db.pool import AsyncDatabase as DatabaseConnection
21
+ from oxyde.migrations.replay import SchemaState
22
+
23
+
24
+ def _is_postgres_enum_add_value_sql(sql: str) -> bool:
25
+ """Detect ALTER TYPE ... ADD VALUE in raw SQL — used only for ctx.execute()."""
26
+ upper = sql.strip().upper()
27
+ return upper.startswith("ALTER TYPE ") and " ADD VALUE " in upper
28
+
29
+
30
+ class MigrationContext:
31
+ """Context for executing migrations.
32
+
33
+ Works in two modes (user doesn't know about this):
34
+ - "collect": Collects operations for replay (used by makemigrations)
35
+ - "execute": Generates and executes SQL (used by migrate)
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ mode: str = "execute",
41
+ db_alias: str | None = None,
42
+ dialect: str | None = None,
43
+ db_conn: DatabaseConnection | None = None,
44
+ schema_state: SchemaState | None = None,
45
+ ) -> None:
46
+ """Initialize migration context.
47
+
48
+ Args:
49
+ mode: Internal mode - "collect" or "execute"
50
+ db_alias: Database alias for execute mode (deprecated, use db_conn)
51
+ dialect: Database dialect (postgres)
52
+ db_conn: Database connection (for execute mode)
53
+ schema_state: Current schema state for migration replay
54
+ """
55
+ self._mode = mode
56
+ self._db_alias = db_alias
57
+ self._dialect = dialect or "postgres"
58
+ from oxyde.migrations.utils import validate_dialect
59
+
60
+ validate_dialect(self._dialect)
61
+ self._db_conn = db_conn
62
+ self._schema_state = schema_state
63
+ self._operations: list[dict[str, Any]] = []
64
+ self._raw_sql_seen = False
65
+ self._sql_statements: list[str] = []
66
+ self._non_transactional_ddl = False
67
+
68
+ @property
69
+ def dialect(self) -> str:
70
+ """Get current database dialect.
71
+
72
+ Returns:
73
+ Database dialect: "postgres"
74
+ """
75
+ return self._dialect
76
+
77
+ def create_enum_type(self, name: str, values: list[str]) -> None:
78
+ op = {
79
+ "type": "create_enum_type",
80
+ "name": name,
81
+ "values": values,
82
+ }
83
+
84
+ if self._mode == "collect":
85
+ self._operations.append(op)
86
+ else:
87
+ self._execute_operation(op)
88
+
89
+ def drop_enum_type(self, name: str) -> None:
90
+ op = {
91
+ "type": "drop_enum_type",
92
+ "name": name,
93
+ }
94
+
95
+ if self._mode == "collect":
96
+ self._operations.append(op)
97
+ else:
98
+ self._execute_operation(op)
99
+
100
+ def add_enum_value(
101
+ self,
102
+ name: str,
103
+ value: str,
104
+ fields: list[dict[str, Any]] | None = None,
105
+ ) -> None:
106
+ op = {
107
+ "type": "add_enum_value",
108
+ "name": name,
109
+ "value": value,
110
+ "fields": fields or [],
111
+ }
112
+
113
+ if self._mode == "collect":
114
+ self._operations.append(op)
115
+ else:
116
+ self._non_transactional_ddl = True
117
+ self._execute_operation(op)
118
+
119
+ def alter_enum_type(
120
+ self,
121
+ name: str,
122
+ old_values: list[str],
123
+ new_values: list[str],
124
+ ) -> None:
125
+ op = {
126
+ "type": "alter_enum_type",
127
+ "name": name,
128
+ "old_values": old_values,
129
+ "new_values": new_values,
130
+ }
131
+
132
+ if self._mode == "collect":
133
+ self._operations.append(op)
134
+
135
+ def require_manual(self, message: str) -> None:
136
+ if self._mode == "execute":
137
+ raise RuntimeError(message)
138
+
139
+ # ========================================================================
140
+ # Table operations
141
+ # ========================================================================
142
+
143
+ def create_table(
144
+ self,
145
+ name: str,
146
+ fields: list[dict[str, Any]],
147
+ indexes: list[dict[str, Any]] | None = None,
148
+ foreign_keys: list[dict[str, Any]] | None = None,
149
+ checks: list[dict[str, Any]] | None = None,
150
+ ) -> None:
151
+ """Create a table.
152
+
153
+ Args:
154
+ name: Table name
155
+ fields: List of field definitions
156
+ indexes: List of index definitions (optional)
157
+ foreign_keys: List of foreign key definitions (optional)
158
+ checks: List of check constraint definitions (optional)
159
+ """
160
+ op = {
161
+ "type": "create_table",
162
+ "table": {
163
+ "name": name,
164
+ "fields": fields,
165
+ "indexes": indexes or [],
166
+ "foreign_keys": foreign_keys or [],
167
+ "checks": checks or [],
168
+ "comment": None,
169
+ },
170
+ }
171
+
172
+ if self._mode == "collect":
173
+ self._operations.append(op)
174
+ else:
175
+ self._execute_operation(op)
176
+
177
+ def drop_table(self, name: str) -> None:
178
+ """Drop a table.
179
+
180
+ Args:
181
+ name: Table name
182
+ """
183
+ op = {"type": "drop_table", "name": name}
184
+
185
+ if self._mode == "collect":
186
+ self._operations.append(op)
187
+ else:
188
+ self._execute_operation(op)
189
+
190
+ def rename_table(self, old_name: str, new_name: str) -> None:
191
+ """Rename a table.
192
+
193
+ Args:
194
+ old_name: Current table name
195
+ new_name: New table name
196
+ """
197
+ op = {
198
+ "type": "rename_table",
199
+ "old_name": old_name,
200
+ "new_name": new_name,
201
+ }
202
+
203
+ if self._mode == "collect":
204
+ self._operations.append(op)
205
+ else:
206
+ self._execute_operation(op)
207
+
208
+ # ========================================================================
209
+ # Column operations
210
+ # ========================================================================
211
+
212
+ def add_column(self, table: str, field: dict[str, Any]) -> None:
213
+ """Add a column to a table.
214
+
215
+ Args:
216
+ table: Table name
217
+ field: Field definition dict
218
+ """
219
+ op = {"type": "add_column", "table": table, "field": field}
220
+
221
+ if self._mode == "collect":
222
+ self._operations.append(op)
223
+ else:
224
+ self._execute_operation(op)
225
+
226
+ def drop_column(self, table: str, field_name: str) -> None:
227
+ """Drop a column from a table.
228
+
229
+ Args:
230
+ table: Table name
231
+ field_name: Column name to drop
232
+ """
233
+ op = {"type": "drop_column", "table": table, "field": field_name}
234
+
235
+ if self._mode == "collect":
236
+ self._operations.append(op)
237
+ else:
238
+ self._execute_operation(op)
239
+
240
+ def rename_column(self, table: str, old_name: str, new_name: str) -> None:
241
+ """Rename a column.
242
+
243
+ Args:
244
+ table: Table name
245
+ old_name: Current column name
246
+ new_name: New column name
247
+ """
248
+ op = {
249
+ "type": "rename_column",
250
+ "table": table,
251
+ "old_name": old_name,
252
+ "new_name": new_name,
253
+ }
254
+
255
+ if self._mode == "collect":
256
+ self._operations.append(op)
257
+ else:
258
+ self._execute_operation(op)
259
+
260
+ def alter_column(self, table: str, field_name: str, **changes: Any) -> None:
261
+ """Alter column properties (nullable, type, default, etc.).
262
+
263
+ Args:
264
+ table: Table name
265
+ field_name: Column name
266
+ **changes: Properties to change (nullable, type, default, etc.)
267
+ """
268
+ # For collect mode, store simple format
269
+ if self._mode == "collect":
270
+ op: dict[str, Any] = {
271
+ "type": "alter_column",
272
+ "table": table,
273
+ "column": field_name,
274
+ "changes": changes,
275
+ }
276
+ self._operations.append(op)
277
+ return
278
+
279
+ # For execute mode, build Rust-compatible format with old_field/new_field
280
+ old_field = None
281
+
282
+ if self._schema_state:
283
+ table_schema = self._schema_state.tables.get(table)
284
+ if table_schema:
285
+ # Find old field definition
286
+ for field in table_schema["fields"]:
287
+ if field["name"] == field_name:
288
+ old_field = dict(field)
289
+ break
290
+
291
+ if old_field is None:
292
+ # Fallback: create minimal field definition
293
+ old_field = {
294
+ "name": field_name,
295
+ "column_type": changes.get("column_type", {"kind": "unknown"}),
296
+ "db_type": None,
297
+ "nullable": True,
298
+ "primary_key": False,
299
+ "unique": False,
300
+ "default": None,
301
+ "auto_increment": False,
302
+ }
303
+
304
+ # Build new_field by applying changes to old_field
305
+ new_field = dict(old_field)
306
+ if "column_type" in changes:
307
+ new_field["column_type"] = changes["column_type"]
308
+ # Legacy keys from old migration files — drop stale column_type so
309
+ # normalization re-derives it from the legacy name
310
+ if "type" in changes:
311
+ new_field["python_type"] = changes["type"]
312
+ new_field.pop("column_type", None)
313
+ if "python_type" in changes:
314
+ new_field["python_type"] = changes["python_type"]
315
+ new_field.pop("column_type", None)
316
+ if "db_type" in changes:
317
+ new_field["db_type"] = changes["db_type"]
318
+ if "nullable" in changes:
319
+ new_field["nullable"] = changes["nullable"]
320
+ if "default" in changes:
321
+ new_field["default"] = changes["default"]
322
+ if "unique" in changes:
323
+ new_field["unique"] = changes["unique"]
324
+ if "max_length" in changes:
325
+ new_field["max_length"] = changes["max_length"]
326
+ if "max_digits" in changes:
327
+ new_field["max_digits"] = changes["max_digits"]
328
+ if "decimal_places" in changes:
329
+ new_field["decimal_places"] = changes["decimal_places"]
330
+
331
+ # Build Rust-compatible operation
332
+ op = {
333
+ "type": "alter_column",
334
+ "table": table,
335
+ "old_field": old_field,
336
+ "new_field": new_field,
337
+ }
338
+
339
+ self._execute_operation(op)
340
+
341
+ # ========================================================================
342
+ # Index operations
343
+ # ========================================================================
344
+
345
+ def create_index(self, table: str, index: dict[str, Any]) -> None:
346
+ """Create an index.
347
+
348
+ Args:
349
+ table: Table name
350
+ index: Index definition dict
351
+ """
352
+ op = {"type": "create_index", "table": table, "index": index}
353
+
354
+ if self._mode == "collect":
355
+ self._operations.append(op)
356
+ else:
357
+ self._execute_operation(op)
358
+
359
+ def drop_index(self, table: str, index_name: str) -> None:
360
+ """Drop an index.
361
+
362
+ Args:
363
+ table: Table name
364
+ index_name: Index name to drop
365
+ """
366
+ op = {"type": "drop_index", "table": table, "name": index_name}
367
+
368
+ if self._mode == "collect":
369
+ self._operations.append(op)
370
+ else:
371
+ self._execute_operation(op)
372
+
373
+ # ========================================================================
374
+ # Foreign key operations
375
+ # ========================================================================
376
+
377
+ def add_foreign_key(
378
+ self,
379
+ table: str,
380
+ name: str,
381
+ columns: list[str],
382
+ ref_table: str,
383
+ ref_columns: list[str],
384
+ on_delete: str = "NO ACTION",
385
+ on_update: str = "NO ACTION",
386
+ ) -> None:
387
+ """Add a foreign key constraint.
388
+
389
+ Args:
390
+ table: Table name
391
+ name: Constraint name
392
+ columns: Local columns
393
+ ref_table: Referenced table name
394
+ ref_columns: Referenced columns
395
+ on_delete: ON DELETE action (CASCADE, SET NULL, RESTRICT, NO ACTION)
396
+ on_update: ON UPDATE action
397
+ """
398
+ op = {
399
+ "type": "add_foreign_key",
400
+ "table": table,
401
+ "fk": {
402
+ "name": name,
403
+ "columns": columns,
404
+ "ref_table": ref_table,
405
+ "ref_columns": ref_columns,
406
+ "on_delete": on_delete,
407
+ "on_update": on_update,
408
+ },
409
+ }
410
+
411
+ if self._mode == "collect":
412
+ self._operations.append(op)
413
+ else:
414
+ self._execute_operation(op)
415
+
416
+ def drop_foreign_key(self, table: str, name: str) -> None:
417
+ """Drop a foreign key constraint.
418
+
419
+ Args:
420
+ table: Table name
421
+ name: Constraint name to drop
422
+ """
423
+ op = {"type": "drop_foreign_key", "table": table, "name": name}
424
+
425
+ if self._mode == "collect":
426
+ self._operations.append(op)
427
+ else:
428
+ self._execute_operation(op)
429
+
430
+ # ========================================================================
431
+ # Check constraint operations
432
+ # ========================================================================
433
+
434
+ def add_check(self, table: str, name: str, expression: str) -> None:
435
+ """Add a check constraint.
436
+
437
+ Args:
438
+ table: Table name
439
+ name: Constraint name
440
+ expression: SQL expression for the check
441
+ """
442
+ op = {
443
+ "type": "add_check",
444
+ "table": table,
445
+ "check": {
446
+ "name": name,
447
+ "expression": expression,
448
+ },
449
+ }
450
+
451
+ if self._mode == "collect":
452
+ self._operations.append(op)
453
+ else:
454
+ self._execute_operation(op)
455
+
456
+ def drop_check(self, table: str, name: str) -> None:
457
+ """Drop a check constraint.
458
+
459
+ Args:
460
+ table: Table name
461
+ name: Constraint name to drop
462
+ """
463
+ op = {"type": "drop_check", "table": table, "name": name}
464
+
465
+ if self._mode == "collect":
466
+ self._operations.append(op)
467
+ else:
468
+ self._execute_operation(op)
469
+
470
+ # ========================================================================
471
+ # Custom operations
472
+ # ========================================================================
473
+
474
+ def execute(self, sql: str) -> None:
475
+ """Execute arbitrary SQL (for data migrations, added manually).
476
+
477
+ This is ignored in "collect" mode (doesn't affect schema structure),
478
+ but the fact is recorded so `oxyde migrations squash` can warn that
479
+ the file contains manual SQL not carried over by the squash.
480
+
481
+ Args:
482
+ sql: SQL statement to execute
483
+ """
484
+ if self._mode == "execute":
485
+ if self._dialect == "postgres" and _is_postgres_enum_add_value_sql(sql):
486
+ self._non_transactional_ddl = True
487
+ self._execute_raw_sql(sql)
488
+ else:
489
+ # Collect mode: schema-neutral, but squash must know about it
490
+ self._raw_sql_seen = True
491
+
492
+ @property
493
+ def has_raw_sql(self) -> bool:
494
+ """True if upgrade() called execute() with manual SQL (collect mode)."""
495
+ return self._raw_sql_seen
496
+
497
+ # ========================================================================
498
+ # Internal methods
499
+ # ========================================================================
500
+
501
+ def get_collected_operations(self) -> list[dict[str, Any]]:
502
+ """Get collected operations (for replay). Internal use only.
503
+
504
+ Returns:
505
+ List of collected operations
506
+ """
507
+ return self._operations
508
+
509
+ def _execute_operation(self, op: dict[str, Any]) -> None:
510
+ """Convert operation to SQL and execute in database.
511
+
512
+ Args:
513
+ op: Operation dictionary
514
+ """
515
+ # Normalize legacy field dicts (python_type form) — Rust requires
516
+ # column_type. Single legacy funnel for the execute path.
517
+ from oxyde.migrations.utils import normalize_op_fields
518
+
519
+ op = normalize_op_fields(op)
520
+
521
+ # Convert operation to SQL using Rust
522
+ operations_json = json.dumps([op])
523
+ sql_statements = migration_to_sql(operations_json, self._dialect)
524
+
525
+ # Execute each SQL statement
526
+ for sql in sql_statements:
527
+ self._execute_raw_sql(sql)
528
+
529
+ def _execute_raw_sql(self, sql: str) -> None:
530
+ """Execute SQL in database (collects SQL for batch execution).
531
+
532
+ Args:
533
+ sql: SQL statement
534
+ """
535
+ if self._mode != "execute":
536
+ return
537
+
538
+ # Collect SQL statements for batch execution
539
+ # They will be executed by the executor after upgrade() completes
540
+ self._sql_statements.append(sql)
541
+
542
+ async def _execute_collected_sql(self) -> None:
543
+ """Execute all collected SQL statements wrapped in a transaction.
544
+
545
+ This is called by the executor after upgrade() completes.
546
+
547
+ Transaction behavior by dialect:
548
+ - PostgreSQL: DDL is transactional except ALTER TYPE ADD VALUE chains
549
+
550
+ IMPORTANT: We use the Rust transaction API (begin_transaction/execute_in_transaction)
551
+ instead of raw BEGIN/COMMIT SQL because the connection pool may assign different
552
+ connections to different queries. The Rust transaction API ensures all queries
553
+ in a transaction use the same connection.
554
+ """
555
+ if not self._sql_statements:
556
+ return
557
+
558
+ if self._db_conn is None:
559
+ raise RuntimeError("Cannot execute SQL: no database connection provided")
560
+
561
+ use_transaction = self._should_use_transaction()
562
+
563
+ tx_id = None
564
+ try:
565
+ if use_transaction:
566
+ tx_id = await begin_transaction(self._db_conn.name)
567
+
568
+ for sql in self._sql_statements:
569
+ sql_ir = build_raw_sql_ir(sql=sql)
570
+ if tx_id is not None:
571
+ ir_bytes = msgpack.packb(sql_ir, default=_msgpack_encoder)
572
+ await execute_in_transaction(self._db_conn.name, tx_id, ir_bytes)
573
+ else:
574
+ await self._db_conn.execute(sql_ir)
575
+
576
+ if tx_id is not None:
577
+ await commit_transaction(tx_id)
578
+
579
+ except Exception:
580
+ if tx_id is not None:
581
+ try:
582
+ await rollback_transaction(tx_id)
583
+ except Exception:
584
+ pass # Ignore rollback errors
585
+ raise
586
+
587
+ finally:
588
+ # Clear collected statements
589
+ self._sql_statements = []
590
+ self._non_transactional_ddl = False
591
+
592
+ def _should_use_transaction(self) -> bool:
593
+ if self._non_transactional_ddl:
594
+ return False
595
+ return True
596
+
597
+
598
+ __all__ = ["MigrationContext"]