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/ir.py ADDED
@@ -0,0 +1,380 @@
1
+ """Intermediate Representation (IR) builders for Rust query execution.
2
+
3
+ This module provides functions to construct query IR dicts that are
4
+ serialized to MessagePack and sent to the Rust core for SQL generation.
5
+
6
+ IR Protocol:
7
+ Every IR payload has:
8
+ proto: Version number (currently 1)
9
+ op: Operation type ("select", "insert", "update", "delete", "raw")
10
+ table: Target table name
11
+
12
+ Filter Tree:
13
+ WHERE clauses are represented as a tree of FilterNode dicts:
14
+
15
+ Condition (leaf):
16
+ {"type": "condition", "field": "age", "operator": ">=", "value": 18}
17
+
18
+ AND (branch):
19
+ {"type": "and", "conditions": [node1, node2, ...]}
20
+
21
+ OR (branch):
22
+ {"type": "or", "conditions": [node1, node2]}
23
+
24
+ NOT (branch):
25
+ {"type": "not", "condition": node}
26
+
27
+ Functions:
28
+ filter_condition(field, operator, value) → FilterNode
29
+ filter_and(*nodes) → FilterNode
30
+ filter_or(*nodes) → FilterNode
31
+ filter_not(node) → FilterNode
32
+
33
+ build_select_ir(...) → dict:
34
+ SELECT with columns, filter_tree, order_by, limit, joins, aggregates, etc.
35
+
36
+ build_insert_ir(...) → dict:
37
+ INSERT with values or bulk_values, pk_column for RETURNING.
38
+
39
+ build_update_ir(...) → dict:
40
+ UPDATE with values and filter_tree, or bulk_update for CASE WHEN.
41
+
42
+ build_delete_ir(...) → dict:
43
+ DELETE with filter_tree.
44
+
45
+ build_raw_sql_ir(sql, params) → dict:
46
+ Raw SQL escape hatch.
47
+
48
+ Example IR (SELECT):
49
+ {
50
+ "proto": 1,
51
+ "op": "select",
52
+ "table": "users",
53
+ "cols": ["id", "name", "email"],
54
+ "column_types": {"id": {"kind": "big_integer"}, "name": {"kind": "string"}},
55
+ "filter_tree": {"type": "condition", "field": "active", "operator": "=", "value": true},
56
+ "order_by": [("created_at", "desc")],
57
+ "limit": 10
58
+ }
59
+
60
+ Rust Processing:
61
+ IR bytes → oxyde-codec::QueryIR → oxyde-sql::build_sql() → SQL string
62
+ SQL → oxyde-driver::execute_query() → rows → MessagePack bytes → Python
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ from collections.abc import MutableMapping, Sequence
68
+ from typing import Any
69
+
70
+ IR_PROTO_VERSION = 1
71
+
72
+ FilterNode = MutableMapping[str, Any]
73
+
74
+
75
+ def filter_condition(
76
+ field: str,
77
+ operator: str,
78
+ value: Any,
79
+ *,
80
+ column: str | None = None,
81
+ escape: str | None = None,
82
+ ) -> FilterNode:
83
+ """Create a simple condition node."""
84
+ payload: FilterNode = {
85
+ "type": "condition",
86
+ "field": field,
87
+ "operator": operator,
88
+ "value": value,
89
+ }
90
+ if column is not None and column != field:
91
+ payload["column"] = column
92
+ if escape is not None:
93
+ payload["escape"] = escape
94
+ return payload
95
+
96
+
97
+ def filter_and(*nodes: FilterNode) -> FilterNode:
98
+ """Combine nodes with logical AND."""
99
+ normalized = [_ensure_filter_node(node) for node in nodes if node]
100
+ if not normalized:
101
+ raise ValueError("filter_and() requires at least one condition")
102
+ if len(normalized) == 1:
103
+ return normalized[0]
104
+ return {"type": "and", "conditions": normalized}
105
+
106
+
107
+ def filter_or(*nodes: FilterNode) -> FilterNode:
108
+ """Combine nodes with logical OR."""
109
+ normalized = [_ensure_filter_node(node) for node in nodes if node]
110
+ if len(normalized) < 2:
111
+ raise ValueError("filter_or() requires at least two conditions")
112
+ return {"type": "or", "conditions": normalized}
113
+
114
+
115
+ def filter_not(node: FilterNode) -> FilterNode:
116
+ """Negate a filter node."""
117
+ return {"type": "not", "condition": _ensure_filter_node(node)}
118
+
119
+
120
+ def _ensure_filter_node(node: FilterNode) -> FilterNode:
121
+ if not isinstance(node, MutableMapping) or "type" not in node:
122
+ raise TypeError("Filter node must be a mapping with a 'type' key")
123
+ return node
124
+
125
+
126
+ def build_select_ir(
127
+ *,
128
+ table: str,
129
+ columns: Sequence[str] | None = None,
130
+ column_types: dict[str, dict] | None = None,
131
+ column_db_types: dict[str, str] | None = None,
132
+ model: str | None = None,
133
+ column_mappings: dict[str, str] | None = None,
134
+ filter_tree: FilterNode | None = None,
135
+ distinct: bool | None = None,
136
+ limit: int | None = None,
137
+ offset: int | None = None,
138
+ order_by: Sequence[tuple[str, str]] | None = None,
139
+ joins: Sequence[dict[str, Any]] | None = None,
140
+ group_by: Sequence[str] | None = None,
141
+ having: FilterNode | None = None,
142
+ aggregates: Sequence[dict[str, Any]] | None = None,
143
+ returning: bool | None = None,
144
+ exists: bool | None = None,
145
+ count: bool | None = None,
146
+ lock: str | None = None,
147
+ pk_column: str | None = None,
148
+ union_query: dict[str, Any] | None = None,
149
+ union_all: bool | None = None,
150
+ ) -> dict[str, Any]:
151
+ """Build a SELECT query IR payload.
152
+
153
+ Args:
154
+ column_types: Optional mapping of column name to ColumnTypeSpec dict
155
+ ({"kind": ...}). Used by Rust for typed binding and decoding
156
+ column_db_types: Optional mapping of column name (keyed like
157
+ column_types) to its db_type override. PostgreSQL filters cast
158
+ array values and array-lookup operands to an array override.
159
+ pk_column: Primary key column name, used for deduplication in JOIN queries.
160
+ """
161
+ payload: dict[str, Any] = {
162
+ "proto": IR_PROTO_VERSION,
163
+ "op": "select",
164
+ "table": table,
165
+ }
166
+ # cols is optional when count=True or exists=True
167
+ if columns:
168
+ payload["cols"] = list(columns)
169
+ if column_types:
170
+ payload["column_types"] = dict(column_types)
171
+ if column_db_types:
172
+ payload["column_db_types"] = dict(column_db_types)
173
+ if filter_tree:
174
+ payload["filter_tree"] = filter_tree
175
+ if model:
176
+ payload["model"] = model
177
+ if column_mappings:
178
+ payload["column_mappings"] = dict(column_mappings)
179
+ if distinct is not None:
180
+ payload["distinct"] = bool(distinct)
181
+ if limit is not None:
182
+ payload["limit"] = int(limit)
183
+ if offset is not None:
184
+ payload["offset"] = int(offset)
185
+ if order_by:
186
+ payload["order_by"] = [(str(col), direction) for col, direction in order_by]
187
+ if joins:
188
+ payload["joins"] = list(joins)
189
+ if group_by:
190
+ payload["group_by"] = list(group_by)
191
+ if having:
192
+ payload["having"] = having
193
+ if aggregates:
194
+ payload["aggregates"] = [dict(agg) for agg in aggregates]
195
+ if returning is not None:
196
+ payload["returning"] = bool(returning)
197
+ if exists is not None:
198
+ payload["exists"] = bool(exists)
199
+ if count is not None:
200
+ payload["count"] = bool(count)
201
+ if lock is not None:
202
+ payload["lock"] = lock
203
+ if pk_column is not None:
204
+ payload["pk_column"] = pk_column
205
+ if union_query is not None:
206
+ payload["union_query"] = union_query
207
+ if union_all is not None:
208
+ payload["union_all"] = union_all
209
+ return payload
210
+
211
+
212
+ def build_insert_ir(
213
+ *,
214
+ table: str,
215
+ values: dict[str, Any] | None = None,
216
+ bulk_values: Sequence[dict[str, Any]] | None = None,
217
+ column_types: dict[str, dict] | None = None,
218
+ model: str | None = None,
219
+ returning: bool | None = None,
220
+ pk_column: str | None = None,
221
+ ) -> dict[str, Any]:
222
+ """Build an INSERT query IR payload.
223
+
224
+ Args:
225
+ table: Table name
226
+ values: Single row values dict
227
+ bulk_values: Multiple rows for bulk insert
228
+ column_types: ColumnTypeSpec dicts for typed parameter binding.
229
+ model: Model name for validation
230
+ returning: Whether to return inserted rows
231
+ pk_column: Primary key column name (defaults to "id" if not specified)
232
+ """
233
+ if not values and not bulk_values:
234
+ raise ValueError("INSERT requires either 'values' or 'bulk_values'")
235
+ payload: dict[str, Any] = {
236
+ "proto": IR_PROTO_VERSION,
237
+ "op": "insert",
238
+ "table": table,
239
+ }
240
+ if values:
241
+ payload["values"] = dict(values)
242
+ if bulk_values:
243
+ payload["bulk_values"] = [dict(row) for row in bulk_values]
244
+ if column_types:
245
+ payload["column_types"] = dict(column_types)
246
+ if model:
247
+ payload["model"] = model
248
+ if returning is not None:
249
+ payload["returning"] = bool(returning)
250
+ if pk_column is not None:
251
+ payload["pk_column"] = pk_column
252
+ return payload
253
+
254
+
255
+ def build_update_ir(
256
+ *,
257
+ table: str,
258
+ values: dict[str, Any] | None = None,
259
+ filter_tree: FilterNode | None = None,
260
+ column_types: dict[str, dict] | None = None,
261
+ column_db_types: dict[str, str] | None = None,
262
+ model: str | None = None,
263
+ returning: bool | None = None,
264
+ bulk_update: Sequence[dict[str, Any]] | None = None,
265
+ ) -> dict[str, Any]:
266
+ """Build an UPDATE query IR payload.
267
+
268
+ Args:
269
+ table: Table name
270
+ values: Values to update
271
+ filter_tree: Filter conditions
272
+ column_types: ColumnTypeSpec dicts for typed parameter binding.
273
+ column_db_types: Column db_type overrides for array casts and JSON lookups.
274
+ model: Model name
275
+ returning: Whether to return updated rows
276
+ bulk_update: Bulk update rows
277
+ """
278
+ if not values and not bulk_update:
279
+ raise ValueError("UPDATE requires either 'values' or 'bulk_update'")
280
+ if values and bulk_update:
281
+ raise ValueError("Provide either 'values' or 'bulk_update', not both")
282
+ payload: dict[str, Any] = {
283
+ "proto": IR_PROTO_VERSION,
284
+ "op": "update",
285
+ "table": table,
286
+ }
287
+ if values:
288
+ payload["values"] = dict(values)
289
+ if bulk_update:
290
+ normalized_rows: list[dict[str, Any]] = []
291
+ for entry in bulk_update:
292
+ filters_payload = dict(entry.get("filters") or {})
293
+ values_payload = dict(entry.get("values") or {})
294
+ if not filters_payload or not values_payload:
295
+ raise ValueError(
296
+ "Each bulk_update entry requires 'filters' and 'values'"
297
+ )
298
+ normalized_rows.append(
299
+ {
300
+ "filters": filters_payload,
301
+ "values": values_payload,
302
+ }
303
+ )
304
+ # Wrap in object with "rows" field to match Rust BulkUpdate struct
305
+ payload["bulk_update"] = {"rows": normalized_rows}
306
+ if column_types:
307
+ payload["column_types"] = dict(column_types)
308
+ if column_db_types:
309
+ payload["column_db_types"] = dict(column_db_types)
310
+ if filter_tree:
311
+ payload["filter_tree"] = filter_tree
312
+ if model:
313
+ payload["model"] = model
314
+ if returning is not None:
315
+ payload["returning"] = bool(returning)
316
+ return payload
317
+
318
+
319
+ def build_delete_ir(
320
+ *,
321
+ table: str,
322
+ filter_tree: FilterNode | None = None,
323
+ column_types: dict[str, dict] | None = None,
324
+ column_db_types: dict[str, str] | None = None,
325
+ model: str | None = None,
326
+ returning: bool | None = None,
327
+ ) -> dict[str, Any]:
328
+ """Build a DELETE query IR payload."""
329
+ payload: dict[str, Any] = {
330
+ "proto": IR_PROTO_VERSION,
331
+ "op": "delete",
332
+ "table": table,
333
+ }
334
+ if filter_tree:
335
+ payload["filter_tree"] = filter_tree
336
+ if column_types:
337
+ payload["column_types"] = dict(column_types)
338
+ if column_db_types:
339
+ payload["column_db_types"] = dict(column_db_types)
340
+ if model:
341
+ payload["model"] = model
342
+ if returning is not None:
343
+ payload["returning"] = bool(returning)
344
+ return payload
345
+
346
+
347
+ def build_raw_sql_ir(*, sql: str, params: list[Any] | None = None) -> dict[str, Any]:
348
+ """Build a raw SQL query IR payload.
349
+
350
+ Args:
351
+ sql: Raw SQL statement
352
+ params: Optional parameters for the SQL statement
353
+
354
+ Returns:
355
+ IR payload dict
356
+ """
357
+ payload: dict[str, Any] = {
358
+ "proto": IR_PROTO_VERSION,
359
+ "op": "raw",
360
+ "table": "", # Empty table for raw SQL (required by QueryIR)
361
+ "sql": sql,
362
+ }
363
+ if params:
364
+ payload["params"] = list(params)
365
+ return payload
366
+
367
+
368
+ __all__ = [
369
+ "IR_PROTO_VERSION",
370
+ "FilterNode",
371
+ "filter_condition",
372
+ "filter_and",
373
+ "filter_or",
374
+ "filter_not",
375
+ "build_select_ir",
376
+ "build_raw_sql_ir",
377
+ "build_insert_ir",
378
+ "build_update_ir",
379
+ "build_delete_ir",
380
+ ]
oxyde/core/types.py ADDED
@@ -0,0 +1,69 @@
1
+ """Unified type registry for the Oxyde ORM.
2
+
3
+ TYPE_REGISTRY maps Python types to their ORM-level descriptors:
4
+ - category: lookup category for query filters
5
+ - serialize: value → msgpack-safe value
6
+
7
+ Column type classification lives in `core/column_types.py`
8
+ (`compute_column_type`); this registry covers the remaining roles.
9
+ Uses exact type() lookup — no issubclass ordering issues (bool vs int).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from datetime import date, datetime, time, timedelta
17
+ from decimal import Decimal
18
+ from enum import Enum
19
+ from typing import Any
20
+ from uuid import UUID
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class TypeDescriptor:
25
+ """Describes how a Python type maps to ORM operations."""
26
+
27
+ category: str # "string", "numeric", "datetime", "time", "uuid", "bool", "generic"
28
+ serialize: Callable[[Any], Any] # value → msgpack-safe value
29
+
30
+
31
+ TYPE_REGISTRY: dict[type, TypeDescriptor] = {
32
+ bool: TypeDescriptor("bool", lambda v: v),
33
+ int: TypeDescriptor("numeric", lambda v: v),
34
+ float: TypeDescriptor("numeric", lambda v: v),
35
+ str: TypeDescriptor("string", lambda v: v),
36
+ bytes: TypeDescriptor("generic", lambda v: v),
37
+ bytearray: TypeDescriptor("generic", bytes),
38
+ datetime: TypeDescriptor("datetime", lambda v: v.isoformat()),
39
+ date: TypeDescriptor("datetime", lambda v: v.isoformat()),
40
+ # Own category: comparisons apply, but a time-of-day has no calendar
41
+ # part, so date-part lookups (year/month/day) must not be offered.
42
+ time: TypeDescriptor("time", lambda v: v.isoformat()),
43
+ timedelta: TypeDescriptor("generic", lambda v: int(v.total_seconds() * 1_000_000)),
44
+ UUID: TypeDescriptor("uuid", str),
45
+ Decimal: TypeDescriptor("numeric", str),
46
+ dict: TypeDescriptor("generic", lambda v: v),
47
+ }
48
+
49
+
50
+ def serialize_value(value: Any) -> Any:
51
+ """Serialize a value for msgpack/IR using TYPE_REGISTRY.
52
+
53
+ Handles lists, tuples (as lists) and dicts recursively. Returns value
54
+ unchanged if type is not in the registry (int, str, float, bool, None
55
+ pass through as-is since msgpack handles them natively).
56
+ """
57
+ if isinstance(value, (list, tuple)):
58
+ return [serialize_value(v) for v in value]
59
+ if isinstance(value, dict):
60
+ return {k: serialize_value(v) for k, v in value.items()}
61
+ if isinstance(value, Enum):
62
+ return value.value
63
+ desc = TYPE_REGISTRY.get(type(value))
64
+ if desc is not None:
65
+ return desc.serialize(value)
66
+ return value
67
+
68
+
69
+ __all__ = ["TypeDescriptor", "TYPE_REGISTRY", "serialize_value"]
oxyde/core/wrapper.py ADDED
@@ -0,0 +1,242 @@
1
+ """Dynamic import of _oxyde_core Rust extension with ABI version checking.
2
+
3
+ This module handles the import of the Rust PyO3 extension module (_oxyde_core)
4
+ and provides stub functions if the module is not available or incompatible.
5
+
6
+ ABI Version:
7
+ EXPECTED_ABI_VERSION must match __abi_version__ in the Rust module.
8
+ If mismatch, ImportError is raised prompting rebuild.
9
+
10
+ Import Strategy:
11
+ 1. Try to import _oxyde_core
12
+ 2. Check ABI version
13
+ 3. Export available functions with hasattr() checks for backwards compat
14
+ 4. If import fails, create stub functions that raise RuntimeError
15
+
16
+ Why Stub Functions?
17
+ Allows Python code to import oxyde even without the Rust module built.
18
+ Useful for:
19
+ - IDE autocomplete without full build
20
+ - Documentation generation
21
+ - Partial testing (model definitions, IR building)
22
+
23
+ Functions Exported:
24
+ Pool management:
25
+ init_pool, init_pool_overwrite, close_pool, close_all_pools
26
+
27
+ Query execution:
28
+ execute, execute_in_transaction
29
+
30
+ Transactions:
31
+ begin_transaction, commit_transaction, rollback_transaction
32
+ create_savepoint, rollback_to_savepoint, release_savepoint
33
+
34
+ Debugging:
35
+ render_sql, render_sql_debug, explain_query
36
+
37
+ Migrations:
38
+ migration_compute_diff, migration_to_sql
39
+
40
+ Validation:
41
+ register_validator (no-op, not implemented in Rust)
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import sys
47
+ from collections.abc import Callable
48
+ from typing import Any
49
+
50
+ # Expected ABI version - must match __abi_version__ in Rust module
51
+ EXPECTED_ABI_VERSION = 1
52
+
53
+ # Function registry: (rust_name, export_name, is_async, is_noop_fallback)
54
+ # - rust_name: name in _oxyde_core module
55
+ # - export_name: name to export (usually same, but explain -> explain_query)
56
+ # - is_async: True for async functions
57
+ # - is_noop_fallback: True if stub should be no-op instead of raising
58
+ _FUNCTION_REGISTRY: list[tuple[str, str, bool, bool]] = [
59
+ # Pool management
60
+ ("init_pool", "init_pool", True, False),
61
+ ("init_pool_overwrite", "init_pool_overwrite", True, False),
62
+ ("close_pool", "close_pool", True, False),
63
+ ("close_all_pools", "close_all_pools", True, False),
64
+ # Query execution
65
+ ("execute", "execute", True, False),
66
+ ("execute_in_transaction", "execute_in_transaction", True, False),
67
+ # Transactions
68
+ ("begin_transaction", "begin_transaction", True, False),
69
+ ("commit_transaction", "commit_transaction", True, False),
70
+ ("rollback_transaction", "rollback_transaction", True, False),
71
+ ("create_savepoint", "create_savepoint", True, False),
72
+ ("rollback_to_savepoint", "rollback_to_savepoint", True, False),
73
+ ("release_savepoint", "release_savepoint", True, False),
74
+ # Pool introspection
75
+ ("pool_backend", "pool_backend", True, False),
76
+ # Debug/introspection
77
+ ("render_sql", "render_sql", True, False),
78
+ ("render_sql_debug", "render_sql_debug", False, False),
79
+ ("explain", "explain_query", True, False), # Renamed for API consistency
80
+ # Migrations
81
+ ("migration_compute_diff", "migration_compute_diff", False, False),
82
+ ("migration_to_sql", "migration_to_sql", False, False),
83
+ # Validation (no-op - not implemented in Rust, kept for compatibility)
84
+ ("register_validator", "register_validator", False, True),
85
+ ]
86
+
87
+
88
+ def _make_stub(name: str, is_async: bool, is_noop: bool) -> Callable[..., Any]:
89
+ """Create a stub function that either raises RuntimeError or is a no-op."""
90
+ if is_noop:
91
+
92
+ def noop_stub(*args: Any, **kwargs: Any) -> None:
93
+ pass
94
+
95
+ noop_stub.__name__ = name
96
+ noop_stub.__doc__ = f"No-op stub for {name} (Rust module not available)"
97
+ return noop_stub
98
+
99
+ error_msg = (
100
+ f"Rust core module not available or missing '{name}'. "
101
+ "Please rebuild with: maturin develop --release"
102
+ )
103
+
104
+ if is_async:
105
+
106
+ async def async_stub(*args: Any, **kwargs: Any) -> Any:
107
+ raise RuntimeError(error_msg)
108
+
109
+ async_stub.__name__ = name
110
+ async_stub.__doc__ = f"Stub for {name} - raises RuntimeError"
111
+ return async_stub
112
+ else:
113
+
114
+ def sync_stub(*args: Any, **kwargs: Any) -> Any:
115
+ raise RuntimeError(error_msg)
116
+
117
+ sync_stub.__name__ = name
118
+ sync_stub.__doc__ = f"Stub for {name} - raises RuntimeError"
119
+ return sync_stub
120
+
121
+
122
+ def _load_functions() -> dict[str, Callable[..., Any]]:
123
+ """Load functions from Rust module or create stubs."""
124
+ exports: dict[str, Callable[..., Any]] = {}
125
+
126
+ try:
127
+ import _oxyde_core # type: ignore[import-untyped, import-not-found, unused-ignore]
128
+
129
+ # ABI version check
130
+ abi_version = getattr(_oxyde_core, "__abi_version__", None)
131
+ if abi_version is None:
132
+ raise ImportError("Rust core module does not expose __abi_version__")
133
+ if abi_version != EXPECTED_ABI_VERSION:
134
+ raise ImportError(
135
+ f"ABI version mismatch: expected {EXPECTED_ABI_VERSION}, "
136
+ f"got {abi_version}. Please reinstall oxyde-postgres-core."
137
+ )
138
+
139
+ # Load each function from registry
140
+ for rust_name, export_name, is_async, is_noop in _FUNCTION_REGISTRY:
141
+ if hasattr(_oxyde_core, rust_name):
142
+ exports[export_name] = getattr(_oxyde_core, rust_name)
143
+ else:
144
+ # Function missing in Rust module (older version?)
145
+ exports[export_name] = _make_stub(export_name, is_async, is_noop)
146
+
147
+ except ImportError as e:
148
+ print(f"Warning: Failed to import Rust core module: {e}", file=sys.stderr)
149
+ print("Oxyde ORM will not be fully functional.", file=sys.stderr)
150
+
151
+ # Create all stubs
152
+ for _, export_name, is_async, is_noop in _FUNCTION_REGISTRY:
153
+ exports[export_name] = _make_stub(export_name, is_async, is_noop)
154
+
155
+ return exports
156
+
157
+
158
+ # Load functions once at module import
159
+ _exports = _load_functions()
160
+
161
+ # Export individual functions for IDE support and type checking
162
+ execute = _exports["execute"]
163
+ execute_in_transaction = _exports["execute_in_transaction"]
164
+ init_pool = _exports["init_pool"]
165
+ init_pool_overwrite = _exports["init_pool_overwrite"]
166
+ close_pool = _exports["close_pool"]
167
+ close_all_pools = _exports["close_all_pools"]
168
+ begin_transaction = _exports["begin_transaction"]
169
+ commit_transaction = _exports["commit_transaction"]
170
+ rollback_transaction = _exports["rollback_transaction"]
171
+ create_savepoint = _exports["create_savepoint"]
172
+ rollback_to_savepoint = _exports["rollback_to_savepoint"]
173
+ release_savepoint = _exports["release_savepoint"]
174
+ register_validator = _exports["register_validator"]
175
+ pool_backend = _exports["pool_backend"]
176
+ render_sql = _exports["render_sql"]
177
+ render_sql_debug = _exports["render_sql_debug"]
178
+ explain_query = _exports["explain_query"]
179
+ migration_compute_diff = _exports["migration_compute_diff"]
180
+ migration_to_sql = _exports["migration_to_sql"]
181
+
182
+ # ── Core exception classes ────────────────────────────────────────────────
183
+ # Raised by the Rust core; constraint classification happens in the driver
184
+ # (sqlx ErrorKind), never by matching error message text. Stubs keep
185
+ # isinstance() checks working when the extension is unavailable.
186
+ try:
187
+ from _oxyde_core import ( # type: ignore[import-untyped, import-not-found, unused-ignore]
188
+ CheckViolationError,
189
+ DatabaseError,
190
+ ForeignKeyViolationError,
191
+ IntegrityError,
192
+ NotNullViolationError,
193
+ UniqueViolationError,
194
+ )
195
+ except ImportError: # pragma: no cover - extension missing
196
+
197
+ class DatabaseError(RuntimeError): # type: ignore[no-redef]
198
+ """Stub: Rust core module not available."""
199
+
200
+ class IntegrityError(DatabaseError): # type: ignore[no-redef]
201
+ """Stub: Rust core module not available."""
202
+
203
+ class UniqueViolationError(IntegrityError): # type: ignore[no-redef]
204
+ """Stub: Rust core module not available."""
205
+
206
+ class ForeignKeyViolationError(IntegrityError): # type: ignore[no-redef]
207
+ """Stub: Rust core module not available."""
208
+
209
+ class NotNullViolationError(IntegrityError): # type: ignore[no-redef]
210
+ """Stub: Rust core module not available."""
211
+
212
+ class CheckViolationError(IntegrityError): # type: ignore[no-redef]
213
+ """Stub: Rust core module not available."""
214
+
215
+
216
+ __all__ = [
217
+ "execute",
218
+ "execute_in_transaction",
219
+ "init_pool",
220
+ "init_pool_overwrite",
221
+ "close_pool",
222
+ "close_all_pools",
223
+ "begin_transaction",
224
+ "commit_transaction",
225
+ "rollback_transaction",
226
+ "create_savepoint",
227
+ "rollback_to_savepoint",
228
+ "release_savepoint",
229
+ "register_validator",
230
+ "pool_backend",
231
+ "render_sql",
232
+ "render_sql_debug",
233
+ "explain_query",
234
+ "migration_compute_diff",
235
+ "migration_to_sql",
236
+ "DatabaseError",
237
+ "IntegrityError",
238
+ "UniqueViolationError",
239
+ "ForeignKeyViolationError",
240
+ "NotNullViolationError",
241
+ "CheckViolationError",
242
+ ]