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.
- oxyde/__init__.py +151 -0
- oxyde/_msgpack.py +3 -0
- oxyde/cli/__init__.py +7 -0
- oxyde/cli/app.py +107 -0
- oxyde/cli/codegen.py +66 -0
- oxyde/cli/config.py +84 -0
- oxyde/cli/migrations.py +536 -0
- oxyde/codegen/__init__.py +15 -0
- oxyde/codegen/stub_generator.py +1229 -0
- oxyde/core/__init__.py +90 -0
- oxyde/core/column_types.py +367 -0
- oxyde/core/ir.py +380 -0
- oxyde/core/types.py +69 -0
- oxyde/core/wrapper.py +242 -0
- oxyde/db/__init__.py +230 -0
- oxyde/db/pool.py +305 -0
- oxyde/db/registry.py +113 -0
- oxyde/db/schema.py +51 -0
- oxyde/db/transaction.py +392 -0
- oxyde/exceptions.py +89 -0
- oxyde/migrations/__init__.py +49 -0
- oxyde/migrations/config.py +187 -0
- oxyde/migrations/context.py +598 -0
- oxyde/migrations/executor.py +456 -0
- oxyde/migrations/extract.py +316 -0
- oxyde/migrations/generator.py +534 -0
- oxyde/migrations/replay.py +367 -0
- oxyde/migrations/squash.py +109 -0
- oxyde/migrations/tracker.py +162 -0
- oxyde/migrations/utils.py +145 -0
- oxyde/models/__init__.py +83 -0
- oxyde/models/base.py +871 -0
- oxyde/models/decorators.py +111 -0
- oxyde/models/field.py +204 -0
- oxyde/models/lookups.py +667 -0
- oxyde/models/metadata.py +174 -0
- oxyde/models/registry.py +170 -0
- oxyde/models/serializers.py +144 -0
- oxyde/models/utils.py +116 -0
- oxyde/py.typed +0 -0
- oxyde/queries/__init__.py +79 -0
- oxyde/queries/aggregates.py +145 -0
- oxyde/queries/base.py +249 -0
- oxyde/queries/conditions.py +92 -0
- oxyde/queries/expressions.py +171 -0
- oxyde/queries/insert.py +124 -0
- oxyde/queries/joins.py +66 -0
- oxyde/queries/manager.py +409 -0
- oxyde/queries/mixins/__init__.py +68 -0
- oxyde/queries/mixins/aggregation.py +335 -0
- oxyde/queries/mixins/debug.py +154 -0
- oxyde/queries/mixins/execution.py +701 -0
- oxyde/queries/mixins/filtering.py +144 -0
- oxyde/queries/mixins/joining.py +160 -0
- oxyde/queries/mixins/mutation.py +508 -0
- oxyde/queries/mixins/pagination.py +145 -0
- oxyde/queries/q.py +250 -0
- oxyde/queries/raw.py +114 -0
- oxyde/queries/select.py +345 -0
- oxyde/queries/typed.py +213 -0
- oxyde_postgres-0.1.0.dist-info/METADATA +141 -0
- oxyde_postgres-0.1.0.dist-info/RECORD +66 -0
- oxyde_postgres-0.1.0.dist-info/WHEEL +5 -0
- oxyde_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- oxyde_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"]
|