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,145 @@
|
|
|
1
|
+
"""SQL aggregate and scalar functions for SELECT queries.
|
|
2
|
+
|
|
3
|
+
This module provides Python wrappers for SQL aggregate functions
|
|
4
|
+
used with annotate() and direct aggregate queries.
|
|
5
|
+
|
|
6
|
+
Aggregate Functions:
|
|
7
|
+
Count(field="*", distinct=False) → COUNT(*) or COUNT(DISTINCT field)
|
|
8
|
+
Sum(field) → SUM(field)
|
|
9
|
+
Avg(field) → AVG(field)
|
|
10
|
+
Max(field) → MAX(field)
|
|
11
|
+
Min(field) → MIN(field)
|
|
12
|
+
|
|
13
|
+
Scalar Functions:
|
|
14
|
+
Concat(*fields, separator="") → Concatenate strings
|
|
15
|
+
Coalesce(*fields) → First non-NULL value
|
|
16
|
+
|
|
17
|
+
Raw SQL:
|
|
18
|
+
RawSQL(sql) → Inject raw SQL expression
|
|
19
|
+
|
|
20
|
+
Usage with annotate():
|
|
21
|
+
# Add computed columns to query results
|
|
22
|
+
query = User.objects.annotate(
|
|
23
|
+
post_count=Count("id"),
|
|
24
|
+
total_score=Sum("score"),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
Usage with group_by():
|
|
28
|
+
query = Order.objects.annotate(
|
|
29
|
+
total=Sum("amount"),
|
|
30
|
+
).group_by("customer_id")
|
|
31
|
+
|
|
32
|
+
Direct aggregates (execute immediately):
|
|
33
|
+
total = await User.objects.filter(active=True).count()
|
|
34
|
+
avg_age = await User.objects.avg("age")
|
|
35
|
+
max_score = await User.objects.max("score")
|
|
36
|
+
|
|
37
|
+
IR Format:
|
|
38
|
+
Count("id").to_ir() returns:
|
|
39
|
+
{"func": "count", "field": "id"}
|
|
40
|
+
|
|
41
|
+
Count("id", distinct=True).to_ir() returns:
|
|
42
|
+
{"func": "count", "field": "id", "distinct": True}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Aggregate:
|
|
51
|
+
"""Base class for aggregate functions."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, field: str, *, distinct: bool = False):
|
|
54
|
+
self.field = field
|
|
55
|
+
self.distinct = distinct
|
|
56
|
+
self.func_name = self.__class__.__name__.lower()
|
|
57
|
+
|
|
58
|
+
def to_ir(self) -> dict[str, Any]:
|
|
59
|
+
"""Convert to IR representation."""
|
|
60
|
+
ir: dict[str, Any] = {
|
|
61
|
+
"func": self.func_name,
|
|
62
|
+
"field": self.field,
|
|
63
|
+
}
|
|
64
|
+
if self.distinct:
|
|
65
|
+
ir["distinct"] = True
|
|
66
|
+
return ir
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Count(Aggregate):
|
|
70
|
+
"""COUNT aggregate function."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, field: str = "*", *, distinct: bool = False):
|
|
73
|
+
super().__init__(field, distinct=distinct)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Sum(Aggregate):
|
|
77
|
+
"""SUM aggregate function."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Avg(Aggregate):
|
|
81
|
+
"""AVG aggregate function."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Max(Aggregate):
|
|
85
|
+
"""MAX aggregate function."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Min(Aggregate):
|
|
89
|
+
"""MIN aggregate function."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Concat(Aggregate):
|
|
93
|
+
"""String concatenation."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, *fields: str, separator: str = ""):
|
|
96
|
+
self.fields = fields
|
|
97
|
+
self.separator = separator
|
|
98
|
+
self.func_name = "concat"
|
|
99
|
+
|
|
100
|
+
def to_ir(self) -> dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"func": "concat",
|
|
103
|
+
"fields": list(self.fields),
|
|
104
|
+
"separator": self.separator,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Coalesce(Aggregate):
|
|
109
|
+
"""COALESCE function - returns first non-NULL value."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, *fields: str):
|
|
112
|
+
self.fields = fields
|
|
113
|
+
self.func_name = "coalesce"
|
|
114
|
+
|
|
115
|
+
def to_ir(self) -> dict[str, Any]:
|
|
116
|
+
return {
|
|
117
|
+
"func": "coalesce",
|
|
118
|
+
"fields": list(self.fields),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class RawSQL:
|
|
123
|
+
"""Raw SQL expression."""
|
|
124
|
+
|
|
125
|
+
def __init__(self, sql: str):
|
|
126
|
+
self.sql = sql
|
|
127
|
+
|
|
128
|
+
def to_ir(self) -> dict[str, Any]:
|
|
129
|
+
return {
|
|
130
|
+
"type": "raw",
|
|
131
|
+
"sql": self.sql,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
__all__ = [
|
|
136
|
+
"Aggregate",
|
|
137
|
+
"Count",
|
|
138
|
+
"Sum",
|
|
139
|
+
"Avg",
|
|
140
|
+
"Max",
|
|
141
|
+
"Min",
|
|
142
|
+
"Concat",
|
|
143
|
+
"Coalesce",
|
|
144
|
+
"RawSQL",
|
|
145
|
+
]
|
oxyde/queries/base.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Shared utilities, protocols, and helpers for query builders.
|
|
2
|
+
|
|
3
|
+
This module provides common functionality used across all query types
|
|
4
|
+
(SELECT, INSERT, UPDATE, DELETE).
|
|
5
|
+
|
|
6
|
+
Protocol:
|
|
7
|
+
SupportsExecute: Interface for objects that can execute queries.
|
|
8
|
+
- AsyncDatabase implements this
|
|
9
|
+
- AsyncTransaction implements this
|
|
10
|
+
Any object with async execute(ir: dict) -> bytes works.
|
|
11
|
+
|
|
12
|
+
Type Variables:
|
|
13
|
+
TQuery: TypeVar bound to Query for method chaining with correct return types.
|
|
14
|
+
|
|
15
|
+
Helper Functions:
|
|
16
|
+
_model_key(model_class) -> str:
|
|
17
|
+
Returns "{module}.{qualname}" for model identification.
|
|
18
|
+
|
|
19
|
+
_resolve_registered_model(key) -> type[Model]:
|
|
20
|
+
Look up model by key or class name in registry.
|
|
21
|
+
|
|
22
|
+
_primary_key_meta(model_class) -> ColumnMeta:
|
|
23
|
+
Get metadata for primary key field.
|
|
24
|
+
|
|
25
|
+
_collect_model_columns(model_class) -> list[(field, column)]:
|
|
26
|
+
Get all (field_name, db_column) pairs.
|
|
27
|
+
|
|
28
|
+
_map_values_to_columns(model_class, values) -> dict:
|
|
29
|
+
Convert field names to database column names in a dict.
|
|
30
|
+
|
|
31
|
+
_resolve_execution_client(using, client) -> SupportsExecute:
|
|
32
|
+
Resolve connection from using="alias" or client object.
|
|
33
|
+
Checks active transaction first.
|
|
34
|
+
|
|
35
|
+
_resolve_pool_name(using, client) -> str:
|
|
36
|
+
Get pool name for schema/explain operations.
|
|
37
|
+
|
|
38
|
+
Caching:
|
|
39
|
+
_TYPE_ADAPTER_CACHE: Thread-safe cache for Pydantic TypeAdapter instances.
|
|
40
|
+
Used for efficient list[Model] validation in fetch_models().
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import threading
|
|
46
|
+
from typing import (
|
|
47
|
+
TYPE_CHECKING,
|
|
48
|
+
Any,
|
|
49
|
+
Protocol,
|
|
50
|
+
TypeVar,
|
|
51
|
+
runtime_checkable,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
from pydantic import TypeAdapter
|
|
55
|
+
|
|
56
|
+
from oxyde.core import wrapper as _core
|
|
57
|
+
from oxyde.db.pool import AsyncDatabase
|
|
58
|
+
from oxyde.db.registry import get_connection
|
|
59
|
+
from oxyde.db.transaction import AsyncTransaction, get_active_transaction
|
|
60
|
+
from oxyde.exceptions import (
|
|
61
|
+
CheckViolationError,
|
|
62
|
+
FieldLookupError,
|
|
63
|
+
ForeignKeyViolationError,
|
|
64
|
+
IntegrityError,
|
|
65
|
+
ManagerError,
|
|
66
|
+
NotNullViolationError,
|
|
67
|
+
UniqueViolationError,
|
|
68
|
+
)
|
|
69
|
+
from oxyde.models.metadata import ColumnMeta
|
|
70
|
+
from oxyde.models.registry import registered_tables
|
|
71
|
+
from oxyde.models.serializers import _get_virtual_fields
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING:
|
|
74
|
+
from oxyde.models.base import Model
|
|
75
|
+
from oxyde.queries.select import Query
|
|
76
|
+
|
|
77
|
+
# Global cache for TypeAdapter instances (thread-safe)
|
|
78
|
+
_TYPE_ADAPTER_CACHE: dict[type, TypeAdapter] = {}
|
|
79
|
+
_TYPE_ADAPTER_LOCK = threading.Lock()
|
|
80
|
+
|
|
81
|
+
# Core exception → oxyde exception, most specific first (core classes form
|
|
82
|
+
# the same hierarchy, so IntegrityError must be checked last).
|
|
83
|
+
_CORE_ERROR_MAP: tuple[tuple[type[BaseException], type[IntegrityError]], ...] = (
|
|
84
|
+
(_core.UniqueViolationError, UniqueViolationError),
|
|
85
|
+
(_core.ForeignKeyViolationError, ForeignKeyViolationError),
|
|
86
|
+
(_core.NotNullViolationError, NotNullViolationError),
|
|
87
|
+
(_core.CheckViolationError, CheckViolationError),
|
|
88
|
+
(_core.IntegrityError, IntegrityError),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _translate_core_error(exc: BaseException) -> ManagerError | None:
|
|
93
|
+
"""Map a Rust-core exception onto the oxyde hierarchy, if it is one.
|
|
94
|
+
|
|
95
|
+
Classification is done by the driver (sqlx ErrorKind) — no message-text
|
|
96
|
+
matching happens anywhere on the Python side.
|
|
97
|
+
"""
|
|
98
|
+
for core_cls, oxyde_cls in _CORE_ERROR_MAP:
|
|
99
|
+
if isinstance(exc, core_cls):
|
|
100
|
+
return oxyde_cls(str(exc))
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _model_key(model_class: type[Model]) -> str:
|
|
105
|
+
"""Return fully qualified identifier for a model."""
|
|
106
|
+
return f"{model_class.__module__}.{model_class.__qualname__}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@runtime_checkable
|
|
110
|
+
class SupportsExecute(Protocol):
|
|
111
|
+
"""Protocol for objects that can execute queries."""
|
|
112
|
+
|
|
113
|
+
async def execute(self, ir: dict[str, Any]) -> bytes: ...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# TypeVar for query builders (covariant for subclassing)
|
|
117
|
+
TQuery = TypeVar("TQuery", bound="Query")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _resolve_registered_model(model_key: str) -> type[Model]:
|
|
121
|
+
"""Resolve a model by its fully qualified key or simple class name."""
|
|
122
|
+
tables = registered_tables()
|
|
123
|
+
# Try exact match first
|
|
124
|
+
model = tables.get(model_key)
|
|
125
|
+
if model is not None:
|
|
126
|
+
return model
|
|
127
|
+
# Fallback: search by simple class name (for forward refs and test classes)
|
|
128
|
+
for key, table_model in tables.items():
|
|
129
|
+
if key.endswith(f".{model_key}") or table_model.__name__ == model_key:
|
|
130
|
+
return table_model
|
|
131
|
+
raise FieldLookupError(f"Related model '{model_key}' is not registered")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _primary_key_meta(model_class: type[Model]) -> ColumnMeta:
|
|
135
|
+
"""Get primary key metadata from model (uses cached pk_field)."""
|
|
136
|
+
pk = model_class._db_meta.pk_field
|
|
137
|
+
if pk is not None:
|
|
138
|
+
return model_class._db_meta.field_metadata[pk]
|
|
139
|
+
raise FieldLookupError(f"{model_class.__name__} has no primary key field")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_column_types(model_class: type[Model]) -> dict[str, dict] | None:
|
|
143
|
+
"""Get cached column_types mapping (ColumnTypeSpec dicts) from model metadata."""
|
|
144
|
+
return model_class._db_meta.column_types
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _build_column_db_types(model_class: type[Model]) -> dict[str, str] | None:
|
|
148
|
+
"""Get cached db_type overrides by column from model metadata."""
|
|
149
|
+
return model_class._db_meta.column_db_types
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _collect_model_columns(model_class: type[Model]) -> list[tuple[str, str]]:
|
|
153
|
+
"""Collect all (field_name, db_column) pairs from model.
|
|
154
|
+
|
|
155
|
+
Excludes virtual fields (reverse FK, M2M, FK model fields) using
|
|
156
|
+
_get_virtual_fields() as the single source of truth.
|
|
157
|
+
"""
|
|
158
|
+
virtual = _get_virtual_fields(model_class)
|
|
159
|
+
return [
|
|
160
|
+
(meta.name, meta.db_column)
|
|
161
|
+
for meta in model_class._db_meta.field_metadata.values()
|
|
162
|
+
if meta.name not in virtual
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _map_values_to_columns(
|
|
167
|
+
model_class: type[Model],
|
|
168
|
+
values: dict[str, Any],
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""Map field names to database column names."""
|
|
171
|
+
metadata = model_class._db_meta.field_metadata
|
|
172
|
+
mapped: dict[str, Any] = {}
|
|
173
|
+
for key, value in values.items():
|
|
174
|
+
meta = metadata.get(key)
|
|
175
|
+
column = meta.db_column if meta else key
|
|
176
|
+
mapped[column] = value
|
|
177
|
+
return mapped
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _resolve_execution_client(
|
|
181
|
+
using: str | None,
|
|
182
|
+
client: SupportsExecute | None,
|
|
183
|
+
) -> SupportsExecute:
|
|
184
|
+
"""Resolve the execution client from using/client parameters."""
|
|
185
|
+
if client is not None and using is not None:
|
|
186
|
+
raise ManagerError("Provide either 'client' or 'using', not both")
|
|
187
|
+
if client is not None:
|
|
188
|
+
return client
|
|
189
|
+
|
|
190
|
+
alias = using or "default"
|
|
191
|
+
active_tx = get_active_transaction(alias)
|
|
192
|
+
if active_tx is not None:
|
|
193
|
+
return active_tx
|
|
194
|
+
return await get_connection(alias)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _resolve_pool_name(
|
|
198
|
+
using: str | None,
|
|
199
|
+
client: SupportsExecute | None,
|
|
200
|
+
) -> str:
|
|
201
|
+
"""
|
|
202
|
+
Resolve pool name from using or client parameters.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
using: Database alias
|
|
206
|
+
client: Database client (AsyncDatabase or AsyncTransaction)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Pool name string
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
ManagerError: If both using and client are provided
|
|
213
|
+
"""
|
|
214
|
+
if client is not None and using is not None:
|
|
215
|
+
raise ManagerError("Provide either 'client' or 'using', not both")
|
|
216
|
+
|
|
217
|
+
if client is not None:
|
|
218
|
+
# Try to get pool name from client
|
|
219
|
+
if isinstance(client, AsyncDatabase):
|
|
220
|
+
return client.name
|
|
221
|
+
elif isinstance(client, AsyncTransaction):
|
|
222
|
+
return client._database.name
|
|
223
|
+
else:
|
|
224
|
+
# Unknown client type, try to get name attribute
|
|
225
|
+
if hasattr(client, "name"):
|
|
226
|
+
return str(client.name)
|
|
227
|
+
elif hasattr(client, "_database") and hasattr(client._database, "name"):
|
|
228
|
+
return str(client._database.name)
|
|
229
|
+
else:
|
|
230
|
+
ctype = type(client).__name__
|
|
231
|
+
raise ManagerError(f"Cannot determine pool name from client: {ctype}")
|
|
232
|
+
|
|
233
|
+
# Use alias
|
|
234
|
+
return using or "default"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = [
|
|
238
|
+
"SupportsExecute",
|
|
239
|
+
"TQuery",
|
|
240
|
+
"_model_key",
|
|
241
|
+
"_resolve_registered_model",
|
|
242
|
+
"_primary_key_meta",
|
|
243
|
+
"_collect_model_columns",
|
|
244
|
+
"_map_values_to_columns",
|
|
245
|
+
"_resolve_execution_client",
|
|
246
|
+
"_resolve_pool_name",
|
|
247
|
+
"_TYPE_ADAPTER_CACHE",
|
|
248
|
+
"_TYPE_ADAPTER_LOCK",
|
|
249
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Condition class representing a single WHERE clause comparison.
|
|
2
|
+
|
|
3
|
+
Condition is the leaf node of the filter tree. It represents a single
|
|
4
|
+
comparison like "age >= 18" or "name LIKE '%alice%'".
|
|
5
|
+
|
|
6
|
+
Attributes:
|
|
7
|
+
field: Python field name (e.g., "age")
|
|
8
|
+
operator: SQL operator ("=", ">", "LIKE", "IN", "IS NULL", etc.)
|
|
9
|
+
value: Comparison value (can be None for IS NULL)
|
|
10
|
+
column: Database column name (may differ from field if db_column set)
|
|
11
|
+
escape: Optional LIKE escape character for pattern filters
|
|
12
|
+
|
|
13
|
+
Operators:
|
|
14
|
+
"=" → Equality
|
|
15
|
+
">", ">=" → Greater than
|
|
16
|
+
"<", "<=" → Less than
|
|
17
|
+
"LIKE" → Pattern match (case-sensitive)
|
|
18
|
+
"ILIKE" → Pattern match (case-insensitive, PostgreSQL)
|
|
19
|
+
"IN" → Value in list
|
|
20
|
+
"BETWEEN" → Range (value is [low, high])
|
|
21
|
+
"IS NULL" → NULL check
|
|
22
|
+
"IS NOT NULL" → NOT NULL check
|
|
23
|
+
"ARRAY_CONTAINS" → Array holds every given element
|
|
24
|
+
"ARRAY_CONTAINED_BY" → Every stored element is among the given ones
|
|
25
|
+
"ARRAY_OVERLAP" → At least one element in common
|
|
26
|
+
|
|
27
|
+
Creation:
|
|
28
|
+
Conditions are typically created via Field comparison operators:
|
|
29
|
+
User.name == "Alice" → Condition("name", "=", "Alice")
|
|
30
|
+
User.age >= 18 → Condition("age", ">=", 18)
|
|
31
|
+
|
|
32
|
+
Or via lookups module:
|
|
33
|
+
_build_lookup_conditions(User, "name", "contains", "alice", meta)
|
|
34
|
+
→ Condition("name", "LIKE", "%alice%")
|
|
35
|
+
|
|
36
|
+
IR Format:
|
|
37
|
+
to_ir() returns dict for Rust codec. For a LIKE condition with an
|
|
38
|
+
escape character:
|
|
39
|
+
{
|
|
40
|
+
"type": "condition",
|
|
41
|
+
"field": "name",
|
|
42
|
+
"op": "LIKE",
|
|
43
|
+
"value": "A\\_%",
|
|
44
|
+
"column": "name",
|
|
45
|
+
"escape": "\\"
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
from oxyde.core import ir
|
|
54
|
+
from oxyde.core.ir import FilterNode
|
|
55
|
+
from oxyde.core.types import serialize_value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Condition:
|
|
59
|
+
"""Represents a filter condition."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
field: str,
|
|
64
|
+
operator: str,
|
|
65
|
+
value: Any,
|
|
66
|
+
*,
|
|
67
|
+
column: str | None = None,
|
|
68
|
+
escape: str | None = None,
|
|
69
|
+
):
|
|
70
|
+
self.field = field
|
|
71
|
+
self.operator = operator
|
|
72
|
+
self.value = value
|
|
73
|
+
self.column = column or field
|
|
74
|
+
self.escape = escape
|
|
75
|
+
|
|
76
|
+
def to_ir(self) -> FilterNode:
|
|
77
|
+
"""Convert to IR format expected by the Rust codec.
|
|
78
|
+
|
|
79
|
+
Values are serialized via TYPE_REGISTRY so that date, UUID, Decimal
|
|
80
|
+
etc. are converted to msgpack-safe representations before hitting
|
|
81
|
+
the IR dict.
|
|
82
|
+
"""
|
|
83
|
+
return ir.filter_condition(
|
|
84
|
+
field=self.field,
|
|
85
|
+
operator=self.operator,
|
|
86
|
+
value=serialize_value(self.value),
|
|
87
|
+
column=self.column,
|
|
88
|
+
escape=self.escape,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["Condition"]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""F-expressions for database-side field references and arithmetic.
|
|
2
|
+
|
|
3
|
+
F() allows referencing database columns in UPDATE statements for atomic
|
|
4
|
+
operations that execute entirely in the database, avoiding race conditions.
|
|
5
|
+
|
|
6
|
+
Basic Usage:
|
|
7
|
+
F("price") → Reference column "price"
|
|
8
|
+
F("price") * 1.1 → price * 1.1
|
|
9
|
+
F("count") + 1 → count + 1
|
|
10
|
+
F("a") + F("b") → a + b
|
|
11
|
+
|
|
12
|
+
Supported Operations:
|
|
13
|
+
F("x") + y → Addition
|
|
14
|
+
F("x") - y → Subtraction
|
|
15
|
+
F("x") * y → Multiplication
|
|
16
|
+
F("x") / y → Division
|
|
17
|
+
-F("x") → Negation
|
|
18
|
+
|
|
19
|
+
Operands can be: F(), numbers, or other _Expression objects.
|
|
20
|
+
|
|
21
|
+
Use Cases:
|
|
22
|
+
# Atomic increment (no race condition)
|
|
23
|
+
await User.objects.filter(id=1).update(login_count=F("login_count") + 1)
|
|
24
|
+
|
|
25
|
+
# Percentage increase
|
|
26
|
+
await Product.objects.filter(category="sale").update(price=F("price") * 0.9)
|
|
27
|
+
|
|
28
|
+
# Combine columns
|
|
29
|
+
await Order.objects.update(total=F("price") * F("quantity"))
|
|
30
|
+
|
|
31
|
+
Internal Structure:
|
|
32
|
+
F("name") creates:
|
|
33
|
+
_Expression({"type": "column", "name": "name"})
|
|
34
|
+
|
|
35
|
+
F("x") + 5 creates:
|
|
36
|
+
_Expression({
|
|
37
|
+
"type": "op",
|
|
38
|
+
"op": "add",
|
|
39
|
+
"lhs": {"type": "column", "name": "x"},
|
|
40
|
+
"rhs": {"type": "value", "value": 5}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
_serialize_value_for_ir() wraps expressions in {"__expr__": ...}
|
|
44
|
+
for the Rust IR parser to recognize them. Literal nodes carry an
|
|
45
|
+
optional value_type ColumnTypeSpec dict ({"kind": "decimal"}).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from oxyde.core.column_types import spec_for_literal
|
|
53
|
+
from oxyde.core.types import serialize_value as _serialize_type_value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Expression:
|
|
57
|
+
"""Represents an arithmetic expression usable in mutation statements."""
|
|
58
|
+
|
|
59
|
+
__slots__ = ("_expr",)
|
|
60
|
+
|
|
61
|
+
def __init__(self, expr: dict[str, Any]):
|
|
62
|
+
self._expr = expr
|
|
63
|
+
|
|
64
|
+
def _binary(self, op: str, other: Any) -> _Expression:
|
|
65
|
+
rhs = _coerce_expression(other)
|
|
66
|
+
return _Expression(
|
|
67
|
+
{
|
|
68
|
+
"type": "op",
|
|
69
|
+
"op": op,
|
|
70
|
+
"lhs": self._expr,
|
|
71
|
+
"rhs": rhs._expr,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def __add__(self, other: Any) -> _Expression:
|
|
76
|
+
return self._binary("add", other)
|
|
77
|
+
|
|
78
|
+
def __radd__(self, other: Any) -> _Expression:
|
|
79
|
+
return _coerce_expression(other)._binary("add", self)
|
|
80
|
+
|
|
81
|
+
def __sub__(self, other: Any) -> _Expression:
|
|
82
|
+
return self._binary("sub", other)
|
|
83
|
+
|
|
84
|
+
def __rsub__(self, other: Any) -> _Expression:
|
|
85
|
+
return _coerce_expression(other)._binary("sub", self)
|
|
86
|
+
|
|
87
|
+
def __mul__(self, other: Any) -> _Expression:
|
|
88
|
+
return self._binary("mul", other)
|
|
89
|
+
|
|
90
|
+
def __rmul__(self, other: Any) -> _Expression:
|
|
91
|
+
return _coerce_expression(other)._binary("mul", self)
|
|
92
|
+
|
|
93
|
+
def __truediv__(self, other: Any) -> _Expression:
|
|
94
|
+
return self._binary("div", other)
|
|
95
|
+
|
|
96
|
+
def __rtruediv__(self, other: Any) -> _Expression:
|
|
97
|
+
return _coerce_expression(other)._binary("div", self)
|
|
98
|
+
|
|
99
|
+
def __neg__(self) -> _Expression:
|
|
100
|
+
return _Expression({"type": "neg", "expr": self._expr})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _coerce_expression(value: Any) -> _Expression:
|
|
104
|
+
"""Coerce a value to an _Expression.
|
|
105
|
+
|
|
106
|
+
For known Python types, attach a `value_type` ColumnTypeSpec dict.
|
|
107
|
+
The Rust side uses it to recover typed bindings (Decimal, UUID,
|
|
108
|
+
datetime, ...) that msgpack-encode as strings via `serialize_value`.
|
|
109
|
+
"""
|
|
110
|
+
if isinstance(value, _Expression):
|
|
111
|
+
return value
|
|
112
|
+
if isinstance(value, F):
|
|
113
|
+
return value._expression
|
|
114
|
+
node: dict[str, Any] = {"type": "value", "value": value}
|
|
115
|
+
spec = spec_for_literal(type(value))
|
|
116
|
+
if spec is not None:
|
|
117
|
+
node["value_type"] = spec
|
|
118
|
+
return _Expression(node)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class F:
|
|
122
|
+
"""Field expression helper for arithmetic updates."""
|
|
123
|
+
|
|
124
|
+
__slots__ = ("name", "_expression")
|
|
125
|
+
|
|
126
|
+
def __init__(self, name: str):
|
|
127
|
+
self.name = name
|
|
128
|
+
self._expression = _Expression({"type": "column", "name": name})
|
|
129
|
+
|
|
130
|
+
def __add__(self, other: Any) -> _Expression:
|
|
131
|
+
return self._expression.__add__(other)
|
|
132
|
+
|
|
133
|
+
def __radd__(self, other: Any) -> _Expression:
|
|
134
|
+
return self._expression.__radd__(other)
|
|
135
|
+
|
|
136
|
+
def __sub__(self, other: Any) -> _Expression:
|
|
137
|
+
return self._expression.__sub__(other)
|
|
138
|
+
|
|
139
|
+
def __rsub__(self, other: Any) -> _Expression:
|
|
140
|
+
return self._expression.__rsub__(other)
|
|
141
|
+
|
|
142
|
+
def __mul__(self, other: Any) -> _Expression:
|
|
143
|
+
return self._expression.__mul__(other)
|
|
144
|
+
|
|
145
|
+
def __rmul__(self, other: Any) -> _Expression:
|
|
146
|
+
return self._expression.__rmul__(other)
|
|
147
|
+
|
|
148
|
+
def __truediv__(self, other: Any) -> _Expression:
|
|
149
|
+
return self._expression.__truediv__(other)
|
|
150
|
+
|
|
151
|
+
def __rtruediv__(self, other: Any) -> _Expression:
|
|
152
|
+
return self._expression.__rtruediv__(other)
|
|
153
|
+
|
|
154
|
+
def __neg__(self) -> _Expression:
|
|
155
|
+
return -self._expression
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _serialize_value_for_ir(value: Any) -> Any:
|
|
159
|
+
"""Serialize values for IR, handling expressions and non-msgpack types."""
|
|
160
|
+
if isinstance(value, _Expression):
|
|
161
|
+
return {"__expr__": value._expr}
|
|
162
|
+
if isinstance(value, F):
|
|
163
|
+
return {"__expr__": value._expression._expr}
|
|
164
|
+
if isinstance(value, list):
|
|
165
|
+
return [_serialize_value_for_ir(item) for item in value]
|
|
166
|
+
if isinstance(value, dict):
|
|
167
|
+
return {key: _serialize_value_for_ir(val) for key, val in value.items()}
|
|
168
|
+
return _serialize_type_value(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__ = ["F", "_Expression", "_coerce_expression", "_serialize_value_for_ir"]
|