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,316 @@
1
+ """Extract database schema from registered models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from datetime import date, datetime, time
7
+ from decimal import Decimal
8
+ from enum import Enum
9
+ from typing import Any
10
+ from uuid import UUID
11
+
12
+ from pydantic.fields import PydanticUndefined
13
+
14
+ from oxyde.core.column_types import compute_column_type
15
+ from oxyde.models.base import Model
16
+ from oxyde.models.registry import iter_tables, registered_tables
17
+
18
+
19
+ def _resolve_fk_target(
20
+ target_key: str, target_field: str | None = None
21
+ ) -> tuple[str, str]:
22
+ """Resolve FK target to (table_name, column_name).
23
+
24
+ Args:
25
+ target_key: Model key like "__main__.User" or table name for primitive FK
26
+ target_field: Target field name (for model FK) or column name
27
+ (for primitive FK). If None, auto-detects PK.
28
+
29
+ Returns:
30
+ Tuple of (table_name, column_name)
31
+ """
32
+ # Get all registered tables
33
+ tables = registered_tables()
34
+
35
+ # Try to find by full key or class name
36
+ for key, model_cls in tables.items():
37
+ if key == target_key or model_cls.__name__ == target_key.split(".")[-1]:
38
+ table_name = model_cls._db_meta.table_name or model_cls.__name__.lower()
39
+
40
+ if target_field:
41
+ # Look up the specified field's db_column
42
+ for field_name, field_meta in model_cls._db_meta.field_metadata.items():
43
+ if field_name == target_field:
44
+ return table_name, field_meta.db_column
45
+ # Field not found in metadata - use target_field as column name
46
+ return table_name, target_field
47
+ else:
48
+ # Find PK column (fallback for backwards compatibility)
49
+ pk_column = "id" # default
50
+ for field_name, field_meta in model_cls._db_meta.field_metadata.items():
51
+ if field_meta.primary_key:
52
+ pk_column = field_meta.db_column
53
+ break
54
+ return table_name, pk_column
55
+
56
+ # No matching model found - target_key is likely a plain table name (primitive FK)
57
+ # Use target_key as table name and target_field as column name
58
+ if target_field:
59
+ return target_key, target_field
60
+
61
+ # Fallback: extract table name from key, assume "id" as column
62
+ class_name = target_key.split(".")[-1]
63
+ return class_name.lower(), "id"
64
+
65
+
66
+ def _serialize_default(value: Any, dialect: str) -> str | None:
67
+ """Serialize Python default value to SQL default expression.
68
+
69
+ Args:
70
+ value: Python default value
71
+ dialect: Database dialect
72
+
73
+ Returns:
74
+ SQL default expression string, or None if cannot serialize
75
+ """
76
+ if value is None:
77
+ return "NULL"
78
+
79
+ # Callable defaults (like datetime.now) cannot be serialized
80
+ # User should use db_default for SQL expressions
81
+ if callable(value):
82
+ return None
83
+
84
+ if isinstance(value, Enum):
85
+ escaped = str(value.value).replace("'", "''")
86
+ return f"'{escaped}'"
87
+
88
+ # String - quote it
89
+ if isinstance(value, str):
90
+ # Escape single quotes
91
+ escaped = value.replace("'", "''")
92
+ return f"'{escaped}'"
93
+
94
+ # Boolean
95
+ if isinstance(value, bool):
96
+ return "TRUE" if value else "FALSE"
97
+
98
+ # Numbers
99
+ if isinstance(value, (int, float, Decimal)):
100
+ return str(value)
101
+
102
+ # UUID
103
+ if isinstance(value, UUID):
104
+ return f"'{value}'"
105
+
106
+ # Date/time types
107
+ if isinstance(value, datetime):
108
+ return f"'{value.isoformat()}'"
109
+ if isinstance(value, date):
110
+ return f"'{value.isoformat()}'"
111
+ if isinstance(value, time):
112
+ return f"'{value.isoformat()}'"
113
+
114
+ # Bytes
115
+ if isinstance(value, bytes):
116
+ return f"'\\x{value.hex()}'"
117
+
118
+ # Fallback - try str() but return None if it looks like a repr
119
+ str_val = str(value)
120
+ if str_val.startswith("<") or "object at" in str_val:
121
+ return None
122
+ return str_val
123
+
124
+
125
+ def extract_current_schema(dialect: str = "postgres") -> dict[str, Any]:
126
+ """Extract current schema from registered models.
127
+
128
+ Args:
129
+ dialect: Target database dialect (postgres)
130
+
131
+ Returns:
132
+ Schema snapshot compatible with Rust Snapshot structure:
133
+ {
134
+ "version": 1,
135
+ "tables": {
136
+ "table_name": {
137
+ "name": "table_name",
138
+ "fields": [...],
139
+ "indexes": [...],
140
+ "comment": None
141
+ }
142
+ }
143
+ }
144
+ """
145
+ from oxyde.migrations.utils import validate_dialect
146
+
147
+ validate_dialect(dialect)
148
+ tables = {}
149
+
150
+ for model_cls in iter_tables():
151
+ meta = model_cls._db_meta
152
+
153
+ # Extract fields
154
+ fields = []
155
+ for field_name, field_meta in meta.field_metadata.items():
156
+ # Skip virtual relation fields - these are for ORM, not actual DB columns:
157
+ # 1. FK fields (python_type is Model) - real column is synthetic field
158
+ # 2. Reverse FK fields (db_reverse_fk) - no DB column, just relation
159
+ # 3. M2M fields (db_m2m) - no DB column, uses junction table
160
+ if isinstance(field_meta.python_type, type) and issubclass(
161
+ field_meta.python_type, Model
162
+ ):
163
+ continue
164
+ if field_meta.extra.get("reverse_fk") or field_meta.extra.get("m2m"):
165
+ continue
166
+
167
+ # Determine Python type for SQL mapping
168
+ python_type_for_sql = field_meta.python_type
169
+
170
+ auto_increment = False
171
+
172
+ # Convert default value to SQL expression
173
+ default_val = None
174
+ # Priority: db_default (explicit SQL) > default (Python value)
175
+ if hasattr(field_meta, "db_default") and field_meta.db_default is not None:
176
+ # db_default is a raw SQL expression - use as-is
177
+ default_val = field_meta.db_default
178
+ elif (
179
+ field_meta.default is not None
180
+ and field_meta.default is not PydanticUndefined
181
+ ):
182
+ # Serialize Python value to SQL
183
+ default_val = _serialize_default(field_meta.default, dialect)
184
+
185
+ # Compute the ColumnTypeSpec via the single mapping point;
186
+ # unknown annotations collapse to "unknown" (TEXT DDL, native bind)
187
+ column_type = compute_column_type(
188
+ python_type_for_sql,
189
+ field_meta.db_type,
190
+ max_length=field_meta.max_length,
191
+ max_digits=field_meta.max_digits,
192
+ decimal_places=field_meta.decimal_places,
193
+ ) or {"kind": "unknown"}
194
+
195
+ field_dict: dict[str, Any] = {
196
+ "name": field_meta.db_column,
197
+ "column_type": column_type,
198
+ "db_type": field_meta.db_type, # explicit user override (verbatim DDL)
199
+ "nullable": field_meta.nullable,
200
+ "primary_key": field_meta.primary_key,
201
+ "unique": field_meta.unique,
202
+ "default": default_val,
203
+ "auto_increment": auto_increment,
204
+ }
205
+ for attr in ("max_length", "max_digits", "decimal_places"):
206
+ val = getattr(field_meta, attr, None)
207
+ if val is not None:
208
+ field_dict[attr] = val
209
+ fields.append(field_dict)
210
+
211
+ # Extract indexes
212
+ indexes: list[dict[str, Any]] = []
213
+ if hasattr(meta, "indexes") and meta.indexes:
214
+ for index in meta.indexes:
215
+ # Generate index name if not provided
216
+ index_name = index.name
217
+ if not index_name and index.fields:
218
+ index_name = f"{meta.table_name}_{'_'.join(index.fields)}_idx"
219
+
220
+ indexes.append(
221
+ {
222
+ "name": index_name,
223
+ "fields": list(index.fields),
224
+ "unique": index.unique,
225
+ "method": index.method,
226
+ "where": index.where.strip() or None
227
+ if isinstance(index.where, str)
228
+ else index.where,
229
+ }
230
+ )
231
+
232
+ # Field-level indexes: Field(db_index=True) on any column, FK included.
233
+ # Skipped for unique columns — the UNIQUE constraint already creates
234
+ # an index, an extra plain one is redundant.
235
+ declared_column_sets = {frozenset(index["fields"]) for index in indexes}
236
+ for field_name, field_meta in meta.field_metadata.items():
237
+ if isinstance(field_meta.python_type, type) and issubclass(
238
+ field_meta.python_type, Model
239
+ ):
240
+ continue # virtual FK field — flags live on the synthetic column
241
+ if field_meta.extra.get("reverse_fk") or field_meta.extra.get("m2m"):
242
+ continue
243
+ if not field_meta.index or field_meta.unique:
244
+ continue
245
+
246
+ column = field_meta.db_column
247
+ if frozenset([column]) in declared_column_sets:
248
+ warnings.warn(
249
+ f"Field '{field_name}' on table '{meta.table_name}' declares "
250
+ f"db_index=True, but Meta.indexes already contains an index "
251
+ f"on ('{column}',). Skipping the field-level index — drop "
252
+ "one of the two declarations.",
253
+ stacklevel=2,
254
+ )
255
+ continue
256
+
257
+ indexes.append(
258
+ {
259
+ "name": field_meta.index_name or f"{meta.table_name}_{column}_idx",
260
+ "fields": [column],
261
+ "unique": False,
262
+ "method": field_meta.index_method,
263
+ "where": None,
264
+ }
265
+ )
266
+
267
+ # Extract foreign keys from field metadata
268
+ foreign_keys = []
269
+ for field_name, field_meta in meta.field_metadata.items():
270
+ if field_meta.foreign_key:
271
+ fk = field_meta.foreign_key
272
+ # Resolve target model to actual table name and column
273
+ ref_table, ref_column = _resolve_fk_target(fk.target, fk.target_field)
274
+ # Generate FK constraint name
275
+ fk_name = f"fk_{meta.table_name}_{field_meta.db_column}"
276
+ foreign_keys.append(
277
+ {
278
+ "name": fk_name,
279
+ "columns": [field_meta.db_column],
280
+ "ref_table": ref_table,
281
+ "ref_columns": [ref_column],
282
+ "on_delete": fk.on_delete,
283
+ "on_update": fk.on_update,
284
+ }
285
+ )
286
+
287
+ # Extract check constraints from Meta.constraints
288
+ checks = []
289
+ if hasattr(meta, "constraints") and meta.constraints:
290
+ for i, check in enumerate(meta.constraints):
291
+ # Check object has .expression and .name attributes
292
+ check_name = check.name or f"chk_{meta.table_name}_{i}"
293
+ checks.append(
294
+ {
295
+ "name": check_name,
296
+ "expression": check.expression,
297
+ }
298
+ )
299
+
300
+ # Build table definition
301
+ tables[meta.table_name] = {
302
+ "name": meta.table_name,
303
+ "fields": fields,
304
+ "indexes": indexes,
305
+ "foreign_keys": foreign_keys,
306
+ "checks": checks,
307
+ "comment": meta.comment,
308
+ }
309
+
310
+ return {
311
+ "version": 1,
312
+ "tables": tables,
313
+ }
314
+
315
+
316
+ __all__ = ["extract_current_schema"]