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/queries/q.py ADDED
@@ -0,0 +1,250 @@
1
+ """Q-expressions for complex boolean filter logic (AND/OR/NOT).
2
+
3
+ Q objects allow building complex WHERE clauses that cannot be expressed
4
+ with simple keyword arguments. They support boolean operators &, |, ~.
5
+
6
+ Basic Usage:
7
+ Q(age__gte=18) → age >= 18
8
+ Q(name="Alice") → name = 'Alice'
9
+ Q(status__in=["a","b"]) → status IN ('a', 'b')
10
+
11
+ FK Traversal:
12
+ Q(user__age__gte=18) → JOIN user, WHERE user.age >= 18
13
+ Q(author__name="Bob") → JOIN author, WHERE author.name = 'Bob'
14
+
15
+ Combining with AND:
16
+ Q(age__gte=18) & Q(status="active")
17
+ → age >= 18 AND status = 'active'
18
+
19
+ Combining with OR:
20
+ Q(role="admin") | Q(role="moderator")
21
+ → role = 'admin' OR role = 'moderator'
22
+
23
+ Negation with NOT:
24
+ ~Q(status="banned")
25
+ → NOT (status = 'banned')
26
+
27
+ Complex Nesting:
28
+ Q(age__gte=18) & (Q(status="active") | Q(premium=True))
29
+ → age >= 18 AND (status = 'active' OR premium = true)
30
+
31
+ Implementation:
32
+ Q stores either:
33
+ - _kwargs: dict of field lookups (leaf node)
34
+ - _op + _children: boolean operation with child Q objects
35
+
36
+ to_filter_node(model_class, query) recursively converts Q tree to FilterNode
37
+ IR format that Rust understands. If query is provided and FK paths are used,
38
+ JOINs are automatically added to the query.
39
+
40
+ Usage in Queries:
41
+ User.objects.filter(Q(age__gte=18) | Q(premium=True))
42
+ User.objects.filter(Q(status="active"), name__startswith="A") # mixed
43
+ User.objects.exclude(Q(role="bot") | Q(status="banned"))
44
+ Post.objects.filter(user__age__gte=18) # FK traversal with auto-JOIN
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from typing import TYPE_CHECKING, Any
50
+
51
+ from oxyde.core.ir import FilterNode, filter_and, filter_not, filter_or
52
+ from oxyde.models.lookups import _build_lookup_conditions, _resolve_lookup
53
+
54
+ if TYPE_CHECKING:
55
+ from oxyde.models.base import Model
56
+
57
+
58
+ class Q:
59
+ """
60
+ Q-expression for building complex filter conditions with AND/OR/NOT logic.
61
+
62
+ Usage:
63
+ Q(age__gte=18) & Q(status="active")
64
+ Q(age__gte=18) | Q(premium=True)
65
+ ~Q(status="banned")
66
+ Q(age__gte=18) & (Q(status="active") | Q(status="premium"))
67
+ """
68
+
69
+ _node: FilterNode | None
70
+ _kwargs: dict[str, Any]
71
+ _op: str | None
72
+ _children: list[Q]
73
+
74
+ def __init__(self, *args: Any, **kwargs: Any):
75
+ """
76
+ Create a Q expression from conditions.
77
+
78
+ Args:
79
+ **kwargs: Field lookups (e.g., age__gte=18, name__contains="John")
80
+ """
81
+ self._op = None
82
+ self._children = []
83
+
84
+ if args and not kwargs:
85
+ # Q(condition_node) - wrap existing FilterNode
86
+ if len(args) == 1 and isinstance(args[0], dict):
87
+ self._node = args[0]
88
+ self._kwargs = {}
89
+ else:
90
+ raise ValueError(
91
+ "Q() with positional args must receive a single FilterNode dict"
92
+ )
93
+ elif not args and kwargs:
94
+ # Q(age=18, status="active") - build from kwargs
95
+ self._node = None
96
+ self._kwargs = kwargs
97
+ elif not args and not kwargs:
98
+ # Q() - empty, will be filtered out
99
+ self._node = None
100
+ self._kwargs = {}
101
+ else:
102
+ raise ValueError(
103
+ "Q() accepts either positional FilterNode or keyword lookups, not both"
104
+ )
105
+
106
+ def _ensure_node(
107
+ self,
108
+ model_class: type[Model] | None = None,
109
+ query: Any = None,
110
+ ) -> FilterNode | None:
111
+ """Convert kwargs to FilterNode if needed.
112
+
113
+ Args:
114
+ model_class: Model class for field resolution
115
+ query: Optional query object for adding JOINs when FK paths are used
116
+ """
117
+ if self._node is not None:
118
+ return self._node
119
+ if not self._kwargs:
120
+ return None
121
+
122
+ if model_class is None:
123
+ raise ValueError(
124
+ "Q expression with kwargs requires model_class to resolve fields"
125
+ )
126
+
127
+ conditions: list[FilterNode] = []
128
+
129
+ for key, value in self._kwargs.items():
130
+ # e.g. "name__icontains", or "user__age__gte" through FK relations
131
+ resolved, lookup = _resolve_lookup(model_class, key)
132
+ column_meta = resolved.column_meta
133
+
134
+ qualified_column = None
135
+ if resolved.joins:
136
+ join_path = "__".join(name for name, _ in resolved.joins)
137
+ # Add JOINs to query if provided
138
+ if query is not None:
139
+ query._add_join_path(join_path)
140
+ # Qualified column name "alias.column": the alias is the join
141
+ # path with __ replaced by _
142
+ alias = "_".join(name for name, _ in resolved.joins)
143
+ qualified_column = f"{alias}.{column_meta.db_column}"
144
+
145
+ field_conditions = _build_lookup_conditions(
146
+ resolved.final_model,
147
+ resolved.final_field,
148
+ lookup,
149
+ value,
150
+ column_meta,
151
+ )
152
+ if qualified_column is not None:
153
+ for cond in field_conditions:
154
+ cond.column = qualified_column
155
+ conditions.extend([c.to_ir() for c in field_conditions])
156
+
157
+ if not conditions:
158
+ return None
159
+ if len(conditions) == 1:
160
+ return conditions[0]
161
+ return filter_and(*conditions)
162
+
163
+ def __and__(self, other: Q) -> Q:
164
+ """Combine with AND logic: Q(...) & Q(...)"""
165
+ if not isinstance(other, Q):
166
+ raise TypeError(f"Cannot AND Q with {type(other)}")
167
+ result = Q()
168
+ result._op = "and"
169
+ result._children = [self, other]
170
+ return result
171
+
172
+ def __or__(self, other: Q) -> Q:
173
+ """Combine with OR logic: Q(...) | Q(...)"""
174
+ if not isinstance(other, Q):
175
+ raise TypeError(f"Cannot OR Q with {type(other)}")
176
+ result = Q()
177
+ result._op = "or"
178
+ result._children = [self, other]
179
+ return result
180
+
181
+ def __invert__(self) -> Q:
182
+ """Negate with NOT logic: ~Q(...)"""
183
+ result = Q()
184
+ result._op = "not"
185
+ result._children = [self]
186
+ return result
187
+
188
+ def to_filter_node(
189
+ self,
190
+ model_class: type[Model],
191
+ query: Any = None,
192
+ ) -> FilterNode | None:
193
+ """
194
+ Convert Q expression to FilterNode for IR.
195
+
196
+ Args:
197
+ model_class: Model class for field resolution
198
+ query: Optional query object for adding JOINs when FK paths are used
199
+
200
+ Returns:
201
+ FilterNode dict or None if empty
202
+ """
203
+ # Check if this is a combined Q with operations
204
+ op = getattr(self, "_op", None)
205
+ children = getattr(self, "_children", None)
206
+
207
+ if op and children:
208
+ # Recursively resolve children
209
+ child_nodes: list[FilterNode] = []
210
+ for child in children:
211
+ node = child.to_filter_node(model_class, query)
212
+ if node is not None:
213
+ child_nodes.append(node)
214
+
215
+ if not child_nodes:
216
+ return None
217
+
218
+ if op == "and":
219
+ if len(child_nodes) == 1:
220
+ return child_nodes[0]
221
+ return filter_and(*child_nodes)
222
+ elif op == "or":
223
+ if len(child_nodes) < 2:
224
+ # OR requires at least 2 conditions, return single or None
225
+ return child_nodes[0] if child_nodes else None
226
+ return filter_or(*child_nodes)
227
+ elif op == "not":
228
+ if not child_nodes:
229
+ return None
230
+ return filter_not(child_nodes[0])
231
+
232
+ # Leaf Q expression
233
+ return self._ensure_node(model_class, query)
234
+
235
+ def __repr__(self) -> str:
236
+ op = getattr(self, "_op", None)
237
+ if op:
238
+ children = getattr(self, "_children", [])
239
+ if op == "not":
240
+ return f"~{children[0]!r}"
241
+ else:
242
+ sep = f" {op.upper()} "
243
+ return f"({sep.join(repr(c) for c in children)})"
244
+ elif self._node:
245
+ return f"Q({self._node})"
246
+ else:
247
+ return f"Q({', '.join(f'{k}={v!r}' for k, v in self._kwargs.items())})"
248
+
249
+
250
+ __all__ = ["Q"]
oxyde/queries/raw.py ADDED
@@ -0,0 +1,114 @@
1
+ """Raw SQL execution with automatic connection resolution.
2
+
3
+ This module provides execute_raw() - a function for executing arbitrary SQL
4
+ while respecting the same connection resolution logic as Model.objects.
5
+
6
+ Connection Resolution:
7
+ 1. Explicit client parameter (AsyncDatabase or AsyncTransaction)
8
+ 2. Active transaction (if inside atomic())
9
+ 3. Named connection (using="alias")
10
+ 4. Default connection ("default")
11
+
12
+ Example:
13
+ from oxyde import execute_raw
14
+
15
+ # Simple query (uses default connection)
16
+ users = await execute_raw("SELECT * FROM users WHERE age > $1", [18])
17
+
18
+ # Inside transaction - automatically uses same transaction
19
+ async with transaction.atomic():
20
+ await User.objects.create(name="Alice")
21
+ await execute_raw("INSERT INTO audit_log VALUES ($1, $2)", [1, "created"])
22
+
23
+ # Specific connection
24
+ stats = await execute_raw("SELECT ...", using="analytics")
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from oxyde._msgpack import msgpack
32
+ from oxyde.queries.base import _resolve_execution_client
33
+
34
+ if TYPE_CHECKING:
35
+ from oxyde.queries.base import SupportsExecute
36
+
37
+
38
+ async def execute_raw(
39
+ sql: str,
40
+ params: list[Any] | None = None,
41
+ *,
42
+ using: str | None = None,
43
+ client: SupportsExecute | None = None,
44
+ ) -> list[dict[str, Any]]:
45
+ """
46
+ Execute raw SQL query.
47
+
48
+ Uses the same connection resolution as Model.objects:
49
+ - Active transaction (if inside atomic())
50
+ - Named connection (using="alias")
51
+ - Default connection
52
+
53
+ Args:
54
+ sql: SQL with PostgreSQL placeholders ($1, $2, ...)
55
+ params: Query parameters (use these to prevent SQL injection!)
56
+ using: Connection alias (default: "default")
57
+ client: Explicit client (AsyncDatabase or AsyncTransaction)
58
+
59
+ Returns:
60
+ List of dicts for SELECT queries.
61
+ Empty list for INSERT/UPDATE/DELETE without RETURNING.
62
+
63
+ Raises:
64
+ RuntimeError: If no connection is available.
65
+ ManagerError: If both 'using' and 'client' are provided.
66
+
67
+ Example:
68
+ # Simple SELECT
69
+ users = await execute_raw("SELECT * FROM users WHERE age > $1", [18])
70
+
71
+ # Inside transaction (automatically uses same tx)
72
+ async with transaction.atomic():
73
+ await User.objects.create(name="Alice")
74
+ await execute_raw(
75
+ "INSERT INTO audit_log (user_id, action) VALUES ($1, $2)",
76
+ [1, "created"]
77
+ )
78
+
79
+ # PostgreSQL-specific features
80
+ results = await execute_raw(
81
+ "SELECT * FROM products WHERE metadata @> $1::jsonb",
82
+ ['{"featured": true}']
83
+ )
84
+
85
+ # Different connection
86
+ stats = await execute_raw("SELECT ...", using="analytics")
87
+
88
+ Warning:
89
+ Always use parameterized queries! Never interpolate user input:
90
+
91
+ # GOOD
92
+ await execute_raw("SELECT * FROM users WHERE email = $1", [email])
93
+
94
+ # BAD - SQL injection risk!
95
+ await execute_raw(f"SELECT * FROM users WHERE email = '{email}'")
96
+ """
97
+ execution_client = await _resolve_execution_client(using, client)
98
+
99
+ ir = {
100
+ "proto": 1,
101
+ "op": "raw",
102
+ "table": "",
103
+ "sql": sql,
104
+ "params": params or [],
105
+ }
106
+
107
+ result_bytes = await execution_client.execute(ir)
108
+
109
+ # Columnar format from Rust: (columns, rows) -> list[dict]
110
+ columns, rows = msgpack.unpackb(result_bytes, raw=False)
111
+ return [dict(zip(columns, row)) for row in rows]
112
+
113
+
114
+ __all__ = ["execute_raw"]
@@ -0,0 +1,345 @@
1
+ """SELECT query builder with Django-style fluent interface.
2
+
3
+ This module provides the Query class - the primary way to build SELECT queries
4
+ in Oxyde. Query objects are immutable: each method returns a new Query instance.
5
+
6
+ Architecture:
7
+ Query inherits from multiple mixins that provide different capabilities:
8
+ FilteringMixin: filter(), exclude()
9
+ PaginationMixin: limit(), offset(), order_by(), distinct(), values()
10
+ JoiningMixin: join(), prefetch()
11
+ AggregationMixin: annotate(), group_by(), having(), count(), sum()
12
+ ExecutionMixin: fetch_all(), fetch_models(), exists(), all()
13
+ MutationMixin: update(), delete(), increment()
14
+ DebugMixin: sql(), explain(), union()
15
+
16
+ Immutability:
17
+ All methods return new Query instances via _clone(). The original is unchanged.
18
+
19
+ base = User.objects.filter(active=True)
20
+ admins = base.filter(role="admin") # base is unchanged
21
+ users = base.filter(role="user") # base is unchanged
22
+
23
+ State Attributes:
24
+ _filter_tree: FilterNode tree for WHERE clause
25
+ _limit_value: LIMIT value
26
+ _offset_value: OFFSET value
27
+ _order_by_fields: List of (field, direction) tuples
28
+ _selected_fields: Explicit field list or None for all
29
+ _distinct: DISTINCT flag
30
+ _join_specs: JOIN descriptors
31
+ _annotations: Aggregate aliases {alias: Aggregate}
32
+ _group_by_fields: GROUP BY columns
33
+ _lock_type: "update" | "share" | None for row locking
34
+
35
+ Execution:
36
+ to_ir() converts Query state to dict (Intermediate Representation).
37
+ IR is serialized to MessagePack and sent to Rust for SQL generation.
38
+
39
+ The IR includes column_types (ColumnTypeSpec dicts per column)
40
+ which enables type-aware decoding in Rust without expensive type_info() calls.
41
+
42
+ Example:
43
+ query = (
44
+ User.objects
45
+ .filter(status="active")
46
+ .exclude(role="bot")
47
+ .order_by("-created_at")
48
+ .limit(10)
49
+ )
50
+ ir = query.to_ir()
51
+ # {"table": "user", "columns": [...], "col_types": {"id": "int", ...}, ...}
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
57
+
58
+ from typing_extensions import Self
59
+
60
+ from oxyde.core import ir
61
+ from oxyde.models.utils import _unwrap_optional
62
+ from oxyde.queries.base import _model_key
63
+ from oxyde.queries.joins import _JoinDescriptor
64
+ from oxyde.queries.mixins import (
65
+ AggregationMixin,
66
+ DebugMixin,
67
+ ExecutionMixin,
68
+ FilteringMixin,
69
+ JoiningMixin,
70
+ MutationMixin,
71
+ PaginationMixin,
72
+ )
73
+
74
+ if TYPE_CHECKING:
75
+ from oxyde.models.base import Model
76
+
77
+
78
+ TModel = TypeVar("TModel", bound="Model")
79
+
80
+
81
+ class Query(
82
+ FilteringMixin[TModel],
83
+ PaginationMixin[TModel],
84
+ JoiningMixin[TModel],
85
+ AggregationMixin[TModel],
86
+ ExecutionMixin[TModel],
87
+ MutationMixin[TModel],
88
+ DebugMixin[TModel],
89
+ Generic[TModel],
90
+ ):
91
+ """
92
+ Query builder for SELECT operations.
93
+
94
+ Combines all query capabilities through mixins:
95
+ - FilteringMixin: filter, exclude
96
+ - PaginationMixin: limit, offset, order_by, distinct, values, values_list
97
+ - JoiningMixin: join, prefetch
98
+ - AggregationMixin: annotate, group_by, having, count, sum, avg, max, min
99
+ - ExecutionMixin: fetch_all, fetch_one, fetch_models, exists, all
100
+ - MutationMixin: update, delete, increment
101
+ - DebugMixin: sql, query, explain, union, union_all
102
+ """
103
+
104
+ def __init__(self, model_class: type[TModel]):
105
+ self.model_class = model_class
106
+ # Filtering state
107
+ self._filter_tree: ir.FilterNode | None = None
108
+ # Pagination state
109
+ self._limit_value: int | None = None
110
+ self._offset_value: int | None = None
111
+ self._order_by_fields: list[tuple[str, str]] = []
112
+ self._selected_fields: list[str] | None = None
113
+ self._distinct: bool = False
114
+ self._result_mode: str | None = None
115
+ self._values_flat: bool = False
116
+ # Joining state
117
+ self._join_specs: list[_JoinDescriptor] = []
118
+ self._prefetch_paths: list[str] = []
119
+ # Aggregation state
120
+ self._annotations: dict[str, Any] = {}
121
+ self._group_by_fields: list[str] = []
122
+ self._having: ir.FilterNode | None = None
123
+ # Union state
124
+ self._union_query: Query | None = None
125
+ self._union_all: bool = False
126
+ # Locking state (FOR UPDATE / FOR SHARE)
127
+ self._lock_type: str | None = None
128
+ # Shortcut query flags (count / exists)
129
+ self._count: bool = False
130
+ self._exists: bool = False
131
+
132
+ def _clone(self) -> Self:
133
+ """Create a copy of this query."""
134
+ clone = self.__class__(self.model_class)
135
+ # Filtering
136
+ clone._filter_tree = self._filter_tree
137
+ # Pagination
138
+ clone._limit_value = self._limit_value
139
+ clone._offset_value = self._offset_value
140
+ clone._order_by_fields = list(self._order_by_fields)
141
+ clone._selected_fields = (
142
+ None if self._selected_fields is None else list(self._selected_fields)
143
+ )
144
+ clone._distinct = self._distinct
145
+ clone._result_mode = self._result_mode
146
+ clone._values_flat = self._values_flat
147
+ # Joining
148
+ clone._join_specs = list(self._join_specs)
149
+ clone._prefetch_paths = list(self._prefetch_paths)
150
+ # Aggregation
151
+ clone._annotations = dict(self._annotations)
152
+ clone._group_by_fields = list(self._group_by_fields)
153
+ clone._having = self._having
154
+ # Union
155
+ clone._union_query = self._union_query
156
+ clone._union_all = self._union_all
157
+ # Locking
158
+ clone._lock_type = self._lock_type
159
+ # Shortcut flags
160
+ clone._count = self._count
161
+ clone._exists = self._exists
162
+ return clone
163
+
164
+ def select(self, *fields: str) -> Self:
165
+ """Specify fields to select."""
166
+ if not fields:
167
+ raise ValueError("select() requires at least one column name")
168
+ clone = self._clone()
169
+ clone._selected_fields = list(fields)
170
+ return clone
171
+
172
+ def for_update(self) -> Self:
173
+ """Add FOR UPDATE lock to query.
174
+
175
+ Locks selected rows for update, preventing other transactions
176
+ from modifying them until the current transaction commits.
177
+
178
+
179
+ Returns:
180
+ Query with FOR UPDATE lock
181
+
182
+ Examples:
183
+ async with atomic():
184
+ user = await User.objects.filter(id=1).for_update().first()
185
+ user.balance -= 100
186
+ await user.save()
187
+ """
188
+ clone = self._clone()
189
+ clone._lock_type = "update"
190
+ return clone
191
+
192
+ def for_share(self) -> Self:
193
+ """Add FOR SHARE lock to query.
194
+
195
+ Locks selected rows for reading, preventing other transactions
196
+ from modifying them (but allowing reads) until the current
197
+ transaction commits.
198
+
199
+
200
+ Returns:
201
+ Query with FOR SHARE lock
202
+
203
+ Examples:
204
+ async with atomic():
205
+ users = await User.objects.filter(status="active").for_share().all()
206
+ # Other transactions can read but not modify these rows
207
+ """
208
+ clone = self._clone()
209
+ clone._lock_type = "share"
210
+ return clone
211
+
212
+ def _column_types_for_query(self) -> dict[str, dict] | None:
213
+ """Return base and joined column type specs keyed by SQL/result column names."""
214
+ base_types = self.model_class._db_meta.column_types or {}
215
+ column_types: dict[str, dict] = dict(base_types)
216
+
217
+ for descriptor in self._join_specs:
218
+ target_types = descriptor.target_model._db_meta.column_types or {}
219
+ for field, column in descriptor.columns:
220
+ spec = target_types.get(column)
221
+ if spec is None:
222
+ continue
223
+ # Qualified column reference (filters on the joined table)
224
+ column_types[f"{descriptor.alias}.{column}"] = spec
225
+ # Result column: the SQL aliases joined columns by *field*
226
+ # name, which differs from the column when db_column is set
227
+ column_types[f"{descriptor.result_prefix}__{field}"] = spec
228
+
229
+ return column_types or None
230
+
231
+ def _column_db_types_for_query(self) -> dict[str, str] | None:
232
+ """Return base and joined db_type overrides keyed by SQL column
233
+ reference ("alias.column" for joined columns, as filters name them)."""
234
+ column_db_types = dict(self.model_class._db_meta.column_db_types or {})
235
+
236
+ for descriptor in self._join_specs:
237
+ target_db_types = descriptor.target_model._db_meta.column_db_types or {}
238
+ for _field, column in descriptor.columns:
239
+ db_type = target_db_types.get(column)
240
+ if db_type is not None:
241
+ column_db_types[f"{descriptor.alias}.{column}"] = db_type
242
+
243
+ return column_db_types or None
244
+
245
+ def to_ir(self) -> dict[str, Any]:
246
+ """Convert query to IR format for Rust execution."""
247
+ from oxyde.models.base import Model
248
+
249
+ table_name = self.model_class.get_table_name()
250
+
251
+ # Get fields to select (exclude virtual relation fields and FK model fields)
252
+ if self._selected_fields is None:
253
+ fields = []
254
+ for field_name, field_info in self.model_class.model_fields.items():
255
+ # Skip virtual relation fields (db_reverse_fk, db_m2m)
256
+ if getattr(field_info, "db_reverse_fk", None) or getattr(
257
+ field_info, "db_m2m", False
258
+ ):
259
+ continue
260
+ # Skip FK model fields (user: User) - we select user_id instead
261
+ annotation = field_info.annotation
262
+ if annotation is not None:
263
+ inner_type, _ = _unwrap_optional(annotation)
264
+ if isinstance(inner_type, type) and issubclass(inner_type, Model):
265
+ continue
266
+ fields.append(field_name)
267
+ else:
268
+ if not self._selected_fields:
269
+ raise ValueError("SELECT query must include at least one column")
270
+ fields = self._selected_fields
271
+
272
+ # Convert field names to db_columns for Rust (Rust operates on columns only)
273
+ db_columns = [self._column_for_field(f) for f in fields]
274
+ order_by = [
275
+ (field if field == "?" else self._column_for_field(field), direction)
276
+ for field, direction in self._order_by_fields
277
+ ]
278
+
279
+ # Build filter_tree
280
+ final_filter_tree = self._build_filter_tree()
281
+
282
+ # Convert annotations to aggregates IR format (supports multiple aggregates)
283
+ aggregates_ir = None
284
+ if self._annotations:
285
+ aggregates_ir = []
286
+ for alias, agg_obj in self._annotations.items():
287
+ if not hasattr(agg_obj, "to_ir"):
288
+ raise TypeError(f"annotate() received non-expression: {agg_obj!r}")
289
+ agg_spec = agg_obj.to_ir()
290
+ func_name = agg_spec.get("func", "").lower()
291
+ agg_ir: dict[str, Any] = {
292
+ "op": func_name,
293
+ "field": agg_spec.get("field"),
294
+ "alias": alias,
295
+ }
296
+ if agg_spec.get("distinct"):
297
+ agg_ir["distinct"] = True
298
+ aggregates_ir.append(agg_ir)
299
+
300
+ # Convert group_by fields to column names
301
+ group_by_columns = None
302
+ if self._group_by_fields:
303
+ group_by_columns = [
304
+ self._column_for_field(field) for field in self._group_by_fields
305
+ ]
306
+
307
+ # Use cached col_types from model metadata plus joined column aliases.
308
+ column_types = self._column_types_for_query()
309
+
310
+ # Pass pk_column only for JOIN queries (needed for deduplication)
311
+ pk_column = None
312
+ if self._join_specs:
313
+ pk_column = self.model_class._db_meta.pk_column
314
+
315
+ # Build union IR recursively
316
+ union_ir = None
317
+ if self._union_query is not None:
318
+ union_ir = self._union_query.to_ir()
319
+
320
+ return ir.build_select_ir(
321
+ table=table_name,
322
+ columns=db_columns,
323
+ column_types=column_types,
324
+ column_db_types=self._column_db_types_for_query(),
325
+ model=_model_key(self.model_class),
326
+ column_mappings=None,
327
+ filter_tree=final_filter_tree,
328
+ distinct=self._distinct or None,
329
+ limit=self._limit_value,
330
+ offset=self._offset_value,
331
+ order_by=order_by or None,
332
+ joins=self._join_specs_to_ir() or None,
333
+ group_by=group_by_columns,
334
+ having=self._having,
335
+ aggregates=aggregates_ir,
336
+ lock=self._lock_type,
337
+ pk_column=pk_column,
338
+ union_query=union_ir,
339
+ union_all=self._union_all or None,
340
+ count=self._count or None,
341
+ exists=self._exists or None,
342
+ )
343
+
344
+
345
+ __all__ = ["Query"]