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
oxyde/core/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """Python bindings to the Rust core (_oxyde_core extension module).
2
+
3
+ This module re-exports functions from the Rust PyO3 extension module and
4
+ provides the ir submodule for building query intermediate representations.
5
+
6
+ The Rust core provides:
7
+ - Connection pool management (init_pool, close_pool)
8
+ - Query execution (execute, execute_in_transaction)
9
+ - Transaction control (begin/commit/rollback, savepoints)
10
+ - Migration utilities (compute_diff, to_sql)
11
+
12
+ Architecture:
13
+ Python Query → IR dict → msgpack bytes → Rust → SQL → Database
14
+ Database → Rust → msgpack bytes → Python dict/models
15
+
16
+ Pool Functions:
17
+ init_pool(name, url, settings): Create connection pool in Rust registry.
18
+ init_pool_overwrite(name, url, settings): Replace existing pool.
19
+ close_pool(name): Close specific pool.
20
+ close_all_pools(): Close all pools (shutdown).
21
+
22
+ Execution Functions:
23
+ execute(pool_name, ir_bytes): Execute query, return msgpack result.
24
+ execute_in_transaction(pool_name, tx_id, ir_bytes): Execute in transaction.
25
+
26
+ Transaction Functions:
27
+ begin_transaction(pool_name) → tx_id: Start transaction.
28
+ commit_transaction(tx_id): Commit and release.
29
+ rollback_transaction(tx_id): Rollback and release.
30
+ create_savepoint(tx_id, name): Create savepoint for nested tx.
31
+ rollback_to_savepoint(tx_id, name): Rollback to savepoint.
32
+ release_savepoint(tx_id, name): Release savepoint.
33
+
34
+ Migration Functions:
35
+ migration_compute_diff(models_schema, db_schema): Compute schema diff.
36
+ migration_to_sql(diff, dialect): Generate SQL migration statements.
37
+
38
+ Validation:
39
+ register_validator(model_key, validator): Register Pydantic validator.
40
+ (Currently unused - validation happens in Python.)
41
+
42
+ Submodules:
43
+ ir: Query IR construction helpers (build_select_ir, filter_and, etc.)
44
+ """
45
+
46
+ from . import ir
47
+ from .wrapper import (
48
+ begin_transaction,
49
+ close_all_pools,
50
+ close_pool,
51
+ commit_transaction,
52
+ create_savepoint,
53
+ execute,
54
+ execute_in_transaction,
55
+ explain_query,
56
+ init_pool,
57
+ init_pool_overwrite,
58
+ migration_compute_diff,
59
+ migration_to_sql,
60
+ pool_backend,
61
+ register_validator,
62
+ release_savepoint,
63
+ render_sql,
64
+ render_sql_debug,
65
+ rollback_to_savepoint,
66
+ rollback_transaction,
67
+ )
68
+
69
+ __all__ = [
70
+ "execute",
71
+ "execute_in_transaction",
72
+ "init_pool",
73
+ "init_pool_overwrite",
74
+ "close_pool",
75
+ "close_all_pools",
76
+ "begin_transaction",
77
+ "commit_transaction",
78
+ "rollback_transaction",
79
+ "create_savepoint",
80
+ "rollback_to_savepoint",
81
+ "release_savepoint",
82
+ "pool_backend",
83
+ "register_validator",
84
+ "render_sql",
85
+ "render_sql_debug",
86
+ "explain_query",
87
+ "migration_compute_diff",
88
+ "migration_to_sql",
89
+ "ir",
90
+ ]
@@ -0,0 +1,367 @@
1
+ """The single Python → ColumnTypeSpec mapping point.
2
+
3
+ Converts a field's Python annotation + user ``db_type`` + constraints into a
4
+ ``ColumnTypeSpec`` dict — the tagged form the Rust core deserializes
5
+ (``{"kind": "decimal", "precision": 10, "scale": 2}``).
6
+
7
+ This module is the only place in the Python layer that classifies types.
8
+ Everything else (model finalization, migrations extract, F-expression
9
+ literals) delegates here. ``TYPE_REGISTRY`` keeps its other roles
10
+ (msgpack value serialization, lookup categories) untouched.
11
+
12
+ Two roles of ``db_type`` are split deliberately:
13
+ - its *semantic kind* (for binding/decoding) is derived here via
14
+ ``_KNOWN_DB_TYPES``;
15
+ - its *verbatim DDL string* travels separately (``FieldDef.db_type``)
16
+ and is never classified in Rust.
17
+
18
+ Unknown annotations and unrecognized ``db_type`` strings return ``None``:
19
+ the column is simply omitted from ``column_types`` and Rust falls back to
20
+ native conversion / runtime DB type info — exactly the legacy behavior.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from datetime import date, datetime, time, timedelta
26
+ from decimal import Decimal
27
+ from enum import Enum
28
+ from typing import Any, get_args, get_origin
29
+ from uuid import UUID
30
+
31
+ ColumnSpec = dict[str, Any]
32
+
33
+ # Scalar Python type → spec kind (exact type() lookup, no issubclass).
34
+ _PY_SCALAR_KINDS: dict[type, str] = {
35
+ bool: "boolean",
36
+ int: "big_integer",
37
+ float: "double",
38
+ str: "string",
39
+ bytes: "blob",
40
+ bytearray: "blob",
41
+ datetime: "date_time",
42
+ date: "date",
43
+ time: "time",
44
+ timedelta: "timedelta",
45
+ UUID: "uuid",
46
+ Decimal: "decimal",
47
+ dict: "json",
48
+ }
49
+
50
+ # Uppercased SQL type name (precision stripped) → spec kind.
51
+ # Mirrors the legacy Rust `classify_type` table: only names whose binding
52
+ # semantics are known. Anything else → None (native conversion).
53
+ _KNOWN_DB_TYPES: dict[str, str] = {
54
+ "INT": "big_integer",
55
+ "INTEGER": "big_integer",
56
+ "BIGINT": "big_integer",
57
+ "SMALLINT": "big_integer",
58
+ "TINYINT": "big_integer",
59
+ "SERIAL": "big_integer",
60
+ "BIGSERIAL": "big_integer",
61
+ "SMALLSERIAL": "big_integer",
62
+ "INT2": "big_integer",
63
+ "INT4": "big_integer",
64
+ "INT8": "big_integer",
65
+ "TIMEDELTA": "timedelta",
66
+ "INTERVAL": "timedelta",
67
+ "FLOAT": "double",
68
+ "DOUBLE": "double",
69
+ "REAL": "double",
70
+ "FLOAT4": "double",
71
+ "FLOAT8": "double",
72
+ "DOUBLE PRECISION": "double",
73
+ "BOOL": "boolean",
74
+ "BOOLEAN": "boolean",
75
+ "TEXT": "text",
76
+ "STR": "text",
77
+ "VARCHAR": "string",
78
+ "CHAR": "string",
79
+ "UUID": "uuid",
80
+ "JSON": "json",
81
+ "JSONB": "json_binary",
82
+ "DATETIME": "date_time",
83
+ "TIMESTAMP": "date_time",
84
+ "TIMESTAMPTZ": "date_time_utc",
85
+ "DATE": "date",
86
+ "TIME": "time",
87
+ "TIMETZ": "time",
88
+ "BYTES": "blob",
89
+ "BYTEA": "blob",
90
+ "BLOB": "blob",
91
+ "DECIMAL": "decimal",
92
+ "NUMERIC": "decimal",
93
+ }
94
+
95
+
96
+ def compute_column_type(
97
+ python_type: Any,
98
+ db_type: str | None = None,
99
+ *,
100
+ max_length: int | None = None,
101
+ max_digits: int | None = None,
102
+ decimal_places: int | None = None,
103
+ ) -> ColumnSpec | None:
104
+ """Compute the ColumnTypeSpec dict for a column.
105
+
106
+ Priority: explicit ``db_type`` (semantic kind via _KNOWN_DB_TYPES,
107
+ parameters parsed from e.g. ``NUMERIC(10,2)``/``VARCHAR(100)``),
108
+ otherwise the Python annotation. Returns None when nothing is known —
109
+ the column is then omitted from ``column_types``.
110
+ """
111
+ enum_spec = _spec_from_enum_annotation(python_type, db_type)
112
+ if enum_spec is not None:
113
+ return enum_spec
114
+ if db_type:
115
+ return _spec_from_db_type(db_type)
116
+ return _spec_from_annotation(
117
+ python_type,
118
+ max_length=max_length,
119
+ max_digits=max_digits,
120
+ decimal_places=decimal_places,
121
+ )
122
+
123
+
124
+ def spec_for_literal(value_type: type) -> ColumnSpec | None:
125
+ """Spec for an F-expression literal by its Python type.
126
+
127
+ Used to recover typed bindings (Decimal, UUID, datetime, ...) for values
128
+ that msgpack-encode as strings. Returns None for types msgpack carries
129
+ natively with correct binding.
130
+ """
131
+ if _is_enum_type(value_type):
132
+ return _enum_spec(value_type)
133
+ kind = _PY_SCALAR_KINDS.get(value_type)
134
+ return {"kind": kind} if kind is not None else None
135
+
136
+
137
+ def _spec_from_db_type(db_type: str) -> ColumnSpec | None:
138
+ upper = db_type.upper().strip()
139
+
140
+ if upper.endswith("[]"):
141
+ item = _spec_from_db_type(upper[:-2])
142
+ return {"kind": "array", "item": item} if item else None
143
+
144
+ base, params = _split_type_params(upper)
145
+ kind = _KNOWN_DB_TYPES.get(base)
146
+ if kind is None:
147
+ return None
148
+
149
+ spec: ColumnSpec = {"kind": kind}
150
+ if kind == "string" and params:
151
+ spec["length"] = params[0]
152
+ elif kind == "decimal" and params:
153
+ spec["precision"] = params[0]
154
+ if len(params) > 1:
155
+ spec["scale"] = params[1]
156
+ return spec
157
+
158
+
159
+ def _spec_from_annotation(
160
+ python_type: Any,
161
+ *,
162
+ max_length: int | None = None,
163
+ max_digits: int | None = None,
164
+ decimal_places: int | None = None,
165
+ ) -> ColumnSpec | None:
166
+ origin = get_origin(python_type)
167
+
168
+ # dict[K, V] -> json
169
+ if origin is dict:
170
+ return {"kind": "json"}
171
+
172
+ # list[T] -> array of T; constraints apply to the element
173
+ # (legacy parity: max_length on a list field meant VARCHAR(n)[] elements)
174
+ if origin is list:
175
+ args = get_args(python_type)
176
+ if args:
177
+ item = _spec_from_annotation(
178
+ args[0],
179
+ max_length=max_length,
180
+ max_digits=max_digits,
181
+ decimal_places=decimal_places,
182
+ )
183
+ if item:
184
+ return {"kind": "array", "item": item}
185
+ return None
186
+
187
+ # Union types (including Optional[T]) -> first non-None arg
188
+ if origin is not None:
189
+ for arg in get_args(python_type):
190
+ if arg is not type(None):
191
+ result = _spec_from_annotation(
192
+ arg,
193
+ max_length=max_length,
194
+ max_digits=max_digits,
195
+ decimal_places=decimal_places,
196
+ )
197
+ if result:
198
+ return result
199
+ return None
200
+
201
+ kind = _PY_SCALAR_KINDS.get(python_type)
202
+ if kind is None:
203
+ if _is_enum_type(python_type):
204
+ return _enum_spec(python_type)
205
+ return None
206
+
207
+ spec: ColumnSpec = {"kind": kind}
208
+ if kind == "string" and max_length is not None:
209
+ spec["length"] = max_length
210
+ elif kind == "decimal":
211
+ if max_digits is not None:
212
+ spec["precision"] = max_digits
213
+ if decimal_places is not None:
214
+ spec["scale"] = decimal_places
215
+ return spec
216
+
217
+
218
+ def _spec_from_enum_annotation(
219
+ python_type: Any,
220
+ db_type: str | None,
221
+ ) -> ColumnSpec | None:
222
+ if db_type:
223
+ # Explicit db_type = plain verbatim column, enum machinery off.
224
+ return None
225
+ enum_info = _enum_annotation_info(python_type)
226
+ if enum_info is None:
227
+ return None
228
+ enum_type, is_array = enum_info
229
+ spec = _enum_spec(enum_type)
230
+ return {"kind": "array", "item": spec} if is_array else spec
231
+
232
+
233
+ def _enum_annotation_info(python_type: Any) -> tuple[type[Enum], bool] | None:
234
+ origin = get_origin(python_type)
235
+ if origin is list:
236
+ args = get_args(python_type)
237
+ if not args:
238
+ return None
239
+ inner = _enum_annotation_info(args[0])
240
+ return (inner[0], True) if inner is not None else None
241
+ if origin is not None:
242
+ for arg in get_args(python_type):
243
+ if arg is type(None):
244
+ continue
245
+ enum_info = _enum_annotation_info(arg)
246
+ if enum_info is not None:
247
+ return enum_info
248
+ return None
249
+ return (python_type, False) if _is_enum_type(python_type) else None
250
+
251
+
252
+ def _is_enum_type(value: Any) -> bool:
253
+ return isinstance(value, type) and issubclass(value, Enum)
254
+
255
+
256
+ def _enum_spec(enum_type: type[Enum]) -> ColumnSpec:
257
+ return {
258
+ "kind": "enum",
259
+ "name": _default_enum_type_name(enum_type),
260
+ "values": _enum_values(enum_type),
261
+ }
262
+
263
+
264
+ def _enum_values(enum_type: type[Enum]) -> list[str]:
265
+ values = []
266
+ for member in enum_type:
267
+ value = member.value
268
+ if not isinstance(value, str):
269
+ raise TypeError(
270
+ f"Enum field '{enum_type.__name__}' must define string values. "
271
+ "Use a str-valued Enum for database enum columns, or annotate "
272
+ "the field as int for integer storage."
273
+ )
274
+ values.append(value)
275
+ return values
276
+
277
+
278
+ def _default_enum_type_name(enum_type: type[Enum]) -> str:
279
+ name = enum_type.__name__
280
+ parts: list[str] = []
281
+ for index, char in enumerate(name):
282
+ if char.isupper() and index > 0:
283
+ prev = name[index - 1]
284
+ next_char = name[index + 1] if index + 1 < len(name) else ""
285
+ if prev != "_" and (not prev.isupper() or next_char.islower()):
286
+ parts.append("_")
287
+ parts.append(char.lower())
288
+ return f"{''.join(parts)}_enum"
289
+
290
+
291
+ def _split_type_params(upper: str) -> tuple[str, list[int]]:
292
+ """Split "NUMERIC(10,2)" → ("NUMERIC", [10, 2]); no parens → (name, [])."""
293
+ if "(" not in upper:
294
+ return upper, []
295
+ base, _, rest = upper.partition("(")
296
+ params: list[int] = []
297
+ for part in rest.rstrip(")").split(","):
298
+ part = part.strip()
299
+ if part.isdigit():
300
+ params.append(int(part))
301
+ else:
302
+ return base.strip(), []
303
+ return base.strip(), params
304
+
305
+
306
+ # Legacy migration-file type names ("int", "str", "decimal", "custom_thing",
307
+ # "int[]") → spec kind. Used only by the legacy reader in migrations
308
+ # (to_snapshot normalization); becomes a hard error in 1.0.
309
+ _LEGACY_NAME_KINDS: dict[str, str] = {
310
+ "int": "big_integer",
311
+ "str": "string",
312
+ "float": "double",
313
+ "bool": "boolean",
314
+ "bytes": "blob",
315
+ "datetime": "date_time",
316
+ "date": "date",
317
+ "time": "time",
318
+ "timedelta": "timedelta",
319
+ "uuid": "uuid",
320
+ "decimal": "decimal",
321
+ "json": "json",
322
+ }
323
+
324
+
325
+ def spec_from_legacy_name(
326
+ name: str,
327
+ *,
328
+ max_length: int | None = None,
329
+ max_digits: int | None = None,
330
+ decimal_places: int | None = None,
331
+ ) -> ColumnSpec:
332
+ """Spec from a legacy migration-file type name.
333
+
334
+ Unknown names (custom annotation classes) collapse to ``unknown`` —
335
+ they always rendered as TEXT and bound natively. Never returns None:
336
+ ``FieldDef.column_type`` is required on the Rust side.
337
+ """
338
+ if name.endswith("[]"):
339
+ item = spec_from_legacy_name(
340
+ name[:-2],
341
+ max_length=max_length,
342
+ max_digits=max_digits,
343
+ decimal_places=decimal_places,
344
+ )
345
+ return {"kind": "array", "item": item}
346
+
347
+ kind = _LEGACY_NAME_KINDS.get(name)
348
+ if kind is None:
349
+ return {"kind": "unknown"}
350
+
351
+ spec: ColumnSpec = {"kind": kind}
352
+ if kind == "string" and max_length is not None:
353
+ spec["length"] = max_length
354
+ elif kind == "decimal":
355
+ if max_digits is not None:
356
+ spec["precision"] = max_digits
357
+ if decimal_places is not None:
358
+ spec["scale"] = decimal_places
359
+ return spec
360
+
361
+
362
+ __all__ = [
363
+ "ColumnSpec",
364
+ "compute_column_type",
365
+ "spec_for_literal",
366
+ "spec_from_legacy_name",
367
+ ]