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,335 @@
1
+ """Aggregation mixin for query building."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Container
6
+ from decimal import Decimal
7
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
8
+
9
+ from typing_extensions import Self
10
+
11
+ from oxyde._msgpack import msgpack
12
+ from oxyde.core import ir
13
+ from oxyde.queries.aggregates import Avg, Max, Min, Sum
14
+ from oxyde.queries.base import SupportsExecute, _resolve_execution_client
15
+ from oxyde.queries.q import Q
16
+
17
+ if TYPE_CHECKING:
18
+ from oxyde.models.base import Model
19
+
20
+ # Lookup → SQL operator mapping for annotation fields in HAVING
21
+ _HAVING_LOOKUP_OPS: dict[str, str] = {
22
+ "exact": "=",
23
+ "gt": ">",
24
+ "gte": ">=",
25
+ "lt": "<",
26
+ "lte": "<=",
27
+ }
28
+
29
+
30
+ TModel = TypeVar("TModel", bound="Model")
31
+
32
+
33
+ class AggregationMixin(Generic[TModel]):
34
+ """Mixin providing aggregation capabilities."""
35
+
36
+ # These attributes are defined in the base Query class
37
+ model_class: type[TModel]
38
+ _annotations: dict[str, Any]
39
+ _group_by_fields: list[str]
40
+ _having: ir.FilterNode | None
41
+ _limit_value: int | None
42
+ _offset_value: int | None
43
+ _order_by_fields: list[tuple[str, str]]
44
+ _count: bool
45
+
46
+ def _clone(self) -> Self:
47
+ """Must be implemented by the main Query class."""
48
+ raise NotImplementedError
49
+
50
+ def to_ir(self) -> dict[str, Any]:
51
+ """Must be implemented by the main Query class."""
52
+ raise NotImplementedError
53
+
54
+ def _build_filter_tree(self) -> ir.FilterNode | None:
55
+ """Must be implemented by FilteringMixin."""
56
+ raise NotImplementedError
57
+
58
+ def _join_specs_to_ir(self) -> list[dict[str, Any]]:
59
+ """Must be implemented by JoiningMixin."""
60
+ raise NotImplementedError
61
+
62
+ def annotate(self, **annotations: Any) -> Self:
63
+ """
64
+ Add computed fields using aggregate functions.
65
+
66
+ Args:
67
+ **annotations: Named aggregate expressions
68
+
69
+ Examples:
70
+ from oxyde.queries.aggregates import Count, Avg
71
+ User.objects.annotate(post_count=Count("posts"), avg_age=Avg("age"))
72
+ """
73
+ clone = self._clone()
74
+ clone._annotations.update(annotations)
75
+ return clone
76
+
77
+ def group_by(self, *fields: str) -> Self:
78
+ """
79
+ Add GROUP BY clause.
80
+
81
+ Args:
82
+ *fields: Field names to group by
83
+
84
+ Examples:
85
+ User.objects.group_by("status", "country")
86
+ """
87
+ clone = self._clone()
88
+ clone._group_by_fields.extend(fields)
89
+ return clone
90
+
91
+ def having(self, *q_exprs: Q | Any, **kwargs: Any) -> Self:
92
+ """
93
+ Add HAVING clause for filtering grouped results.
94
+
95
+ Supports both annotation aliases and model fields:
96
+ .having(total__gt=100) # 'total' from annotate(total=Sum(...))
97
+ .having(Q(total__gt=100) | Q(total__lt=10))
98
+
99
+ Args:
100
+ *q_exprs: Q expressions
101
+ **kwargs: Field lookups
102
+
103
+ Examples:
104
+ Post.objects.annotate(total=Sum("views")).group_by("author_id").having(total__gt=100)
105
+ """
106
+ clone = self._clone()
107
+ conditions_to_add: list[ir.FilterNode] = []
108
+
109
+ for q_expr in q_exprs:
110
+ if isinstance(q_expr, Q):
111
+ node = q_expr.to_filter_node(self.model_class)
112
+ if node:
113
+ conditions_to_add.append(node)
114
+
115
+ if kwargs:
116
+ annotation_kwargs = {}
117
+ model_kwargs = {}
118
+ for key, value in kwargs.items():
119
+ field_name, _ = _split_having_key(key, self._annotations)
120
+ if field_name in self._annotations:
121
+ annotation_kwargs[key] = value
122
+ else:
123
+ model_kwargs[key] = value
124
+
125
+ for key, value in annotation_kwargs.items():
126
+ field_name, lookup = _split_having_key(key, self._annotations)
127
+ operator = _HAVING_LOOKUP_OPS.get(lookup)
128
+ if operator is None:
129
+ raise ValueError(
130
+ f"Unsupported lookup '{lookup}' for annotation "
131
+ f"'{field_name}' in having(). "
132
+ f"Supported: {', '.join(sorted(_HAVING_LOOKUP_OPS))}"
133
+ )
134
+ conditions_to_add.append(
135
+ ir.filter_condition(field_name, operator, value)
136
+ )
137
+
138
+ if model_kwargs:
139
+ node = Q(**model_kwargs).to_filter_node(self.model_class)
140
+ if node:
141
+ conditions_to_add.append(node)
142
+
143
+ if conditions_to_add:
144
+ if clone._having:
145
+ conditions_to_add.insert(0, clone._having)
146
+
147
+ if len(conditions_to_add) == 1:
148
+ clone._having = conditions_to_add[0]
149
+ else:
150
+ clone._having = ir.filter_and(*conditions_to_add)
151
+
152
+ return clone
153
+
154
+ async def _aggregate(
155
+ self,
156
+ agg_class: type,
157
+ field: str,
158
+ result_key: str,
159
+ *,
160
+ using: str | None = None,
161
+ client: SupportsExecute | None = None,
162
+ ) -> Any:
163
+ """Execute an aggregate query and return the result."""
164
+ exec_client = await _resolve_execution_client(using, client)
165
+
166
+ # Build aggregate query (without limit/offset/order)
167
+ agg_query = self._clone()
168
+ agg_query._limit_value = None
169
+ agg_query._offset_value = None
170
+ agg_query._order_by_fields = []
171
+ agg_query = agg_query.annotate(**{result_key: agg_class(field)})
172
+
173
+ # Execute and extract value
174
+ query_ir = agg_query.to_ir()
175
+ result_bytes = await exec_client.execute(query_ir)
176
+ result = msgpack.unpackb(result_bytes, raw=False)
177
+
178
+ # Handle columnar format: (columns, rows)
179
+ if isinstance(result, (list, tuple)) and len(result) == 2:
180
+ first, second = result
181
+ if isinstance(first, list) and all(isinstance(c, str) for c in first):
182
+ # Columnar format
183
+ columns = first
184
+ rows = second
185
+ if rows:
186
+ row_dict = dict(zip(columns, rows[0]))
187
+ value = row_dict.get(result_key)
188
+ return self._coerce_aggregate(value, field, agg_class)
189
+ return None
190
+
191
+ if isinstance(result, list) and len(result) > 0:
192
+ row = result[0]
193
+ if isinstance(row, dict):
194
+ return row.get(result_key)
195
+ else:
196
+ return getattr(row, result_key, None)
197
+ return None
198
+
199
+ def _coerce_aggregate(self, value: Any, field: str, agg_class: type) -> Any:
200
+ """Coerce aggregate result to the field's Python type.
201
+
202
+ PG returns NUMERIC for SUM/AVG even on integer fields.
203
+ Rust encodes NUMERIC as string. Coerce back using field metadata.
204
+ """
205
+ if value is None or not isinstance(value, str):
206
+ return value
207
+
208
+ meta = self.model_class._db_meta.field_metadata.get(field)
209
+ if meta is None:
210
+ return value
211
+
212
+ python_type = meta.python_type
213
+
214
+ # AVG always returns float/Decimal, even for int fields
215
+ if agg_class is Avg:
216
+ if python_type is Decimal:
217
+ return Decimal(value)
218
+ return float(value)
219
+
220
+ if python_type is int:
221
+ return int(value)
222
+ if python_type is float:
223
+ return float(value)
224
+ if python_type is Decimal:
225
+ return Decimal(value)
226
+
227
+ return value
228
+
229
+ async def count(
230
+ self,
231
+ *,
232
+ using: str | None = None,
233
+ client: SupportsExecute | None = None,
234
+ ) -> int:
235
+ """
236
+ Count the number of records matching the query.
237
+
238
+ Returns:
239
+ int: Number of records
240
+
241
+ Examples:
242
+ count = await User.objects.filter(is_active=True).count()
243
+ """
244
+ exec_client = await _resolve_execution_client(using, client)
245
+
246
+ # Clone query with count flag — goes through to_ir() so col_types
247
+ # and all other IR fields are included automatically.
248
+ clone = self._clone()
249
+ clone._count = True
250
+ clone._limit_value = None
251
+ clone._offset_value = None
252
+ clone._order_by_fields = []
253
+ query_ir = clone.to_ir()
254
+
255
+ result_bytes = await exec_client.execute(query_ir)
256
+ result = msgpack.unpackb(result_bytes, raw=False)
257
+
258
+ # Handle columnar format: (columns, rows)
259
+ if isinstance(result, (list, tuple)) and len(result) == 2:
260
+ first, second = result
261
+ if isinstance(first, list) and all(isinstance(c, str) for c in first):
262
+ # Columnar format
263
+ columns = first
264
+ rows = second
265
+ if rows:
266
+ row_dict = dict(zip(columns, rows[0]))
267
+ return row_dict.get("_count", 0) or 0
268
+ return 0
269
+
270
+ # Result is [{"_count": N}]
271
+ if isinstance(result, list) and len(result) > 0:
272
+ row = result[0]
273
+ if isinstance(row, dict):
274
+ return row.get("_count", 0) or 0
275
+ return 0
276
+
277
+ async def sum(
278
+ self,
279
+ field: str,
280
+ *,
281
+ using: str | None = None,
282
+ client: SupportsExecute | None = None,
283
+ ) -> Any:
284
+ """Calculate sum of field values."""
285
+ return await self._aggregate(Sum, field, "_sum", using=using, client=client)
286
+
287
+ async def avg(
288
+ self,
289
+ field: str,
290
+ *,
291
+ using: str | None = None,
292
+ client: SupportsExecute | None = None,
293
+ ) -> Any:
294
+ """Calculate average of field values."""
295
+ return await self._aggregate(Avg, field, "_avg", using=using, client=client)
296
+
297
+ async def max(
298
+ self,
299
+ field: str,
300
+ *,
301
+ using: str | None = None,
302
+ client: SupportsExecute | None = None,
303
+ ) -> Any:
304
+ """Get maximum field value."""
305
+ return await self._aggregate(Max, field, "_max", using=using, client=client)
306
+
307
+ async def min(
308
+ self,
309
+ field: str,
310
+ *,
311
+ using: str | None = None,
312
+ client: SupportsExecute | None = None,
313
+ ) -> Any:
314
+ """Get minimum field value."""
315
+ return await self._aggregate(Min, field, "_min", using=using, client=client)
316
+
317
+
318
+ def _split_having_key(key: str, annotations: Container[str] = ()) -> tuple[str, str]:
319
+ """Split a having kwarg key into (field_name, lookup).
320
+
321
+ Keys are matched against annotation aliases, not lookup names: an alias
322
+ is that alias with "exact", even when it ends in a lookup name
323
+ ("tags__contains"), and an alias followed by one more part is that alias
324
+ with that lookup. Any other key is returned whole for the model path.
325
+
326
+ Examples:
327
+ "total__gt" -> ("total", "gt")
328
+ "total" -> ("total", "exact")
329
+ """
330
+ if key in annotations:
331
+ return key, "exact"
332
+ head, sep, tail = key.rpartition("__")
333
+ if sep and head in annotations:
334
+ return head, tail
335
+ return key, "exact"
@@ -0,0 +1,154 @@
1
+ """Debug mixin for query building."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Coroutine
6
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
7
+
8
+ from typing_extensions import Self
9
+
10
+ from oxyde._msgpack import msgpack
11
+ from oxyde.core.wrapper import explain_query, render_sql_debug
12
+ from oxyde.db.pool import _msgpack_encoder
13
+ from oxyde.queries.base import SupportsExecute, _resolve_pool_name
14
+
15
+ if TYPE_CHECKING:
16
+ from oxyde.models.base import Model
17
+
18
+
19
+ TModel = TypeVar("TModel", bound="Model")
20
+
21
+
22
+ class DebugMixin(Generic[TModel]):
23
+ """Mixin providing debugging and introspection capabilities."""
24
+
25
+ # These attributes are defined in the base Query class
26
+ model_class: type[TModel]
27
+ _union_query: DebugMixin | None
28
+ _union_all: bool
29
+
30
+ def _clone(self) -> Self:
31
+ """Must be implemented by the main Query class."""
32
+ raise NotImplementedError
33
+
34
+ def to_ir(self) -> dict[str, Any]:
35
+ """Must be implemented by the main Query class."""
36
+ raise NotImplementedError
37
+
38
+ def sql(
39
+ self,
40
+ *,
41
+ dialect: str = "postgres",
42
+ with_types: bool = False,
43
+ ) -> tuple[str, list[Any]]:
44
+ """
45
+ Return SQL representation and parameters for debugging.
46
+
47
+ This calls Rust's render_sql_debug() to generate real SQL using the same
48
+ logic that executes queries, ensuring consistency. Works without database
49
+ connection.
50
+
51
+ Args:
52
+ dialect: SQL dialect - "postgres"
53
+ with_types: If True, params are returned as (type_tag, value) tuples
54
+ showing the exact sea_query Value variant used for binding.
55
+
56
+ Returns:
57
+ tuple: (sql_string, parameters)
58
+
59
+ Examples:
60
+ sql, params = User.objects.filter(age__gte=18).sql()
61
+ sql, params = User.objects.filter(age__gte=18).sql(with_types=True)
62
+ # params = [("BigInt", 18)]
63
+ """
64
+ query_ir = self.to_ir()
65
+ ir_bytes = msgpack.packb(query_ir, default=_msgpack_encoder)
66
+ result: tuple[str, list[Any]] = render_sql_debug(ir_bytes, dialect, with_types)
67
+ return result
68
+
69
+ def query(self) -> dict[str, Any]:
70
+ """
71
+ Return query IR (Intermediate Representation) for inspection.
72
+
73
+ Returns:
74
+ dict: Query IR structure that would be sent to Rust
75
+
76
+ Examples:
77
+ query_ir = User.objects.filter(age__gte=18).query()
78
+ print(query_ir)
79
+ """
80
+ return self.to_ir()
81
+
82
+ def explain(
83
+ self,
84
+ *,
85
+ using: str | None = None,
86
+ client: SupportsExecute | None = None,
87
+ analyze: bool = False,
88
+ format: str = "text",
89
+ ) -> Coroutine[Any, Any, str]:
90
+ """
91
+ Get query execution plan from the database.
92
+
93
+ Args:
94
+ using: Database alias to use
95
+ client: Optional database client
96
+ analyze: Whether to execute the query and show actual times
97
+ format: Output format - "text" or "json"
98
+
99
+ Returns:
100
+ Query plan from database
101
+
102
+ Examples:
103
+ plan = await User.objects.filter(age__gte=18).explain()
104
+ plan = await User.objects.filter(age__gte=18).explain(analyze=True)
105
+ """
106
+
107
+ async def runner() -> str:
108
+ # Resolve pool name from using/client
109
+ pool_name = _resolve_pool_name(using, client)
110
+
111
+ query_ir = self.to_ir()
112
+ ir_bytes = msgpack.packb(query_ir, default=_msgpack_encoder)
113
+
114
+ # Call Rust explain function
115
+ plan: str = await explain_query(
116
+ pool_name, ir_bytes, analyze=analyze, format=format
117
+ )
118
+ return plan
119
+
120
+ return runner()
121
+
122
+ def union(self, other_query: DebugMixin) -> Self:
123
+ """
124
+ Combine with another query using UNION (removes duplicates).
125
+
126
+ Args:
127
+ other_query: Query to union with
128
+
129
+ Examples:
130
+ active = User.objects.filter(status="active")
131
+ premium = User.objects.filter(status="premium")
132
+ combined = active.union(premium)
133
+ """
134
+ clone = self._clone()
135
+ clone._union_query = other_query
136
+ clone._union_all = False
137
+ return clone
138
+
139
+ def union_all(self, other_query: DebugMixin) -> Self:
140
+ """
141
+ Combine with another query using UNION ALL (keeps duplicates).
142
+
143
+ Args:
144
+ other_query: Query to union with
145
+
146
+ Examples:
147
+ q1 = User.objects.filter(age__gte=18)
148
+ q2 = User.objects.filter(status="premium")
149
+ combined = q1.union_all(q2)
150
+ """
151
+ clone = self._clone()
152
+ clone._union_query = other_query
153
+ clone._union_all = True
154
+ return clone