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
oxyde/queries/insert.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""INSERT query builder for single and bulk inserts (internal).
|
|
2
|
+
|
|
3
|
+
InsertQuery is used internally by QueryManager for create() and bulk_create().
|
|
4
|
+
Use Model.objects.create() and Model.objects.bulk_create() instead.
|
|
5
|
+
|
|
6
|
+
Example (via Manager):
|
|
7
|
+
user = await User.objects.create(name="Alice", age=30)
|
|
8
|
+
users = await User.objects.bulk_create([
|
|
9
|
+
User(name="Alice", age=30),
|
|
10
|
+
User(name="Bob", age=25),
|
|
11
|
+
])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from oxyde._msgpack import msgpack
|
|
19
|
+
from oxyde.core import ir
|
|
20
|
+
from oxyde.queries.base import (
|
|
21
|
+
SupportsExecute,
|
|
22
|
+
_build_column_types,
|
|
23
|
+
_map_values_to_columns,
|
|
24
|
+
_model_key,
|
|
25
|
+
_primary_key_meta,
|
|
26
|
+
)
|
|
27
|
+
from oxyde.queries.expressions import _serialize_value_for_ir
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from oxyde.models.base import Model
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InsertQuery:
|
|
34
|
+
"""INSERT query builder."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, model_class: type[Model]):
|
|
37
|
+
self.model_class = model_class
|
|
38
|
+
self._values: dict[str, Any] = {}
|
|
39
|
+
self._bulk_values: list[dict[str, Any]] | None = None
|
|
40
|
+
|
|
41
|
+
def _clone(self) -> InsertQuery:
|
|
42
|
+
clone = InsertQuery(self.model_class)
|
|
43
|
+
clone._values = dict(self._values)
|
|
44
|
+
clone._bulk_values = list(self._bulk_values) if self._bulk_values else None
|
|
45
|
+
return clone
|
|
46
|
+
|
|
47
|
+
def values(self, **kwargs: Any) -> InsertQuery:
|
|
48
|
+
"""Set values to insert."""
|
|
49
|
+
clone = self._clone()
|
|
50
|
+
clone._values = dict(kwargs)
|
|
51
|
+
clone._bulk_values = None
|
|
52
|
+
return clone
|
|
53
|
+
|
|
54
|
+
def bulk_values(self, values: list[dict[str, Any]]) -> InsertQuery:
|
|
55
|
+
"""Set bulk values for batch insert."""
|
|
56
|
+
clone = self._clone()
|
|
57
|
+
clone._bulk_values = list(values)
|
|
58
|
+
clone._values = {}
|
|
59
|
+
return clone
|
|
60
|
+
|
|
61
|
+
def _get_pk_column(self) -> str | None:
|
|
62
|
+
"""Get the database column name for the primary key field."""
|
|
63
|
+
try:
|
|
64
|
+
pk_meta = _primary_key_meta(self.model_class)
|
|
65
|
+
return pk_meta.db_column
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def to_ir(self) -> dict[str, Any]:
|
|
70
|
+
"""Convert to IR format."""
|
|
71
|
+
table_name = self.model_class.get_table_name()
|
|
72
|
+
pk_column = self._get_pk_column()
|
|
73
|
+
column_types = _build_column_types(self.model_class)
|
|
74
|
+
|
|
75
|
+
if self._bulk_values is not None:
|
|
76
|
+
if not self._bulk_values:
|
|
77
|
+
raise ValueError("Bulk INSERT requires at least one row")
|
|
78
|
+
|
|
79
|
+
# Serialize all rows
|
|
80
|
+
serialized_bulk = []
|
|
81
|
+
for row_values in self._bulk_values:
|
|
82
|
+
mapped = _map_values_to_columns(self.model_class, row_values)
|
|
83
|
+
serialized = {
|
|
84
|
+
key: _serialize_value_for_ir(value) for key, value in mapped.items()
|
|
85
|
+
}
|
|
86
|
+
serialized_bulk.append(serialized)
|
|
87
|
+
|
|
88
|
+
# Bulk insert: only return PKs for efficiency
|
|
89
|
+
return ir.build_insert_ir(
|
|
90
|
+
table=table_name,
|
|
91
|
+
bulk_values=serialized_bulk,
|
|
92
|
+
column_types=column_types,
|
|
93
|
+
model=_model_key(self.model_class),
|
|
94
|
+
pk_column=pk_column,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
if not self._values:
|
|
98
|
+
raise ValueError("INSERT query requires at least one column/value pair")
|
|
99
|
+
|
|
100
|
+
mapped_values = _map_values_to_columns(self.model_class, self._values)
|
|
101
|
+
serialized_values = {
|
|
102
|
+
key: _serialize_value_for_ir(value)
|
|
103
|
+
for key, value in mapped_values.items()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Single insert: use RETURNING * to get all fields (including db defaults)
|
|
107
|
+
return ir.build_insert_ir(
|
|
108
|
+
table=table_name,
|
|
109
|
+
values=serialized_values,
|
|
110
|
+
column_types=column_types,
|
|
111
|
+
model=_model_key(self.model_class),
|
|
112
|
+
pk_column=pk_column,
|
|
113
|
+
returning=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def execute(self, client: SupportsExecute) -> dict[str, Any]:
|
|
117
|
+
"""Execute insert query."""
|
|
118
|
+
ir = self.to_ir()
|
|
119
|
+
result_bytes = await client.execute(ir)
|
|
120
|
+
result: dict[str, Any] = msgpack.unpackb(result_bytes, raw=False)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = ["InsertQuery"]
|
oxyde/queries/joins.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Internal descriptor for JOIN operations in queries.
|
|
2
|
+
|
|
3
|
+
_JoinDescriptor holds all metadata needed to generate a JOIN clause
|
|
4
|
+
and map results back to nested model instances.
|
|
5
|
+
|
|
6
|
+
Attributes:
|
|
7
|
+
path: Dot-separated relation path (e.g., "author", "author.profile")
|
|
8
|
+
alias: SQL table alias for this join (e.g., "t1", "t2")
|
|
9
|
+
result_prefix: Prefix for result columns (e.g., "author__")
|
|
10
|
+
parent_path: Path to parent join (None for direct joins)
|
|
11
|
+
parent_alias: SQL alias of parent table
|
|
12
|
+
attr_name: Attribute name on parent model (e.g., "author")
|
|
13
|
+
source_column: FK column on source table (e.g., "author_id")
|
|
14
|
+
target_column: PK column on target table (e.g., "id")
|
|
15
|
+
columns: List of (field_name, db_column) for SELECT
|
|
16
|
+
target_model: Model class being joined
|
|
17
|
+
parent_model: Model class of the parent
|
|
18
|
+
nullable: Whether this is a LEFT JOIN (True) or INNER JOIN (False)
|
|
19
|
+
|
|
20
|
+
Join Resolution:
|
|
21
|
+
JoiningMixin.join("author") creates _JoinDescriptor:
|
|
22
|
+
- Finds FK field on current model pointing to Author
|
|
23
|
+
- Resolves source_column (author_id) and target_column (id)
|
|
24
|
+
- Generates unique alias
|
|
25
|
+
- Collects columns from target model
|
|
26
|
+
|
|
27
|
+
Nested Joins:
|
|
28
|
+
join("author.profile") creates two descriptors:
|
|
29
|
+
1. Post → Author (path="author")
|
|
30
|
+
2. Author → Profile (path="author.profile", parent_path="author")
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
# Query uses these internally
|
|
34
|
+
query = Post.objects.join("author", "category")
|
|
35
|
+
for desc in query._join_specs:
|
|
36
|
+
print(desc.path, desc.alias) # "author" "t1", "category" "t2"
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from dataclasses import dataclass
|
|
42
|
+
from typing import TYPE_CHECKING
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from oxyde.models.base import Model
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class _JoinDescriptor:
|
|
50
|
+
"""Descriptor for a join operation."""
|
|
51
|
+
|
|
52
|
+
path: str
|
|
53
|
+
alias: str
|
|
54
|
+
result_prefix: str
|
|
55
|
+
parent_path: str | None
|
|
56
|
+
parent_alias: str | None
|
|
57
|
+
attr_name: str
|
|
58
|
+
source_column: str
|
|
59
|
+
target_column: str
|
|
60
|
+
columns: list[tuple[str, str]]
|
|
61
|
+
target_model: type[Model]
|
|
62
|
+
parent_model: type[Model]
|
|
63
|
+
nullable: bool
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["_JoinDescriptor"]
|
oxyde/queries/manager.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Django-style QueryManager for Model.objects interface.
|
|
2
|
+
|
|
3
|
+
This module provides QueryManager - the class behind Model.objects that
|
|
4
|
+
enables Django-like query syntax. Each Model gets a QueryManager
|
|
5
|
+
instance automatically assigned to its `objects` attribute.
|
|
6
|
+
|
|
7
|
+
Design:
|
|
8
|
+
QueryManager is a thin proxy that creates Query instances.
|
|
9
|
+
All actual functionality lives in Query and its mixins.
|
|
10
|
+
Manager methods just return Query(model) with the appropriate method called.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
# Manager is auto-attached to models
|
|
14
|
+
class User(Model):
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
# All methods return Query objects or execute queries
|
|
18
|
+
users = await User.objects.all()
|
|
19
|
+
user = await User.objects.filter(id=1).get()
|
|
20
|
+
new_user = await User.objects.create(name="Alice")
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Coroutine, Iterable
|
|
26
|
+
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, overload
|
|
27
|
+
|
|
28
|
+
from oxyde.exceptions import IntegrityError, ManagerError, NotFoundError
|
|
29
|
+
from oxyde.models.serializers import _derive_create_data
|
|
30
|
+
from oxyde.queries.select import Query
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from oxyde.models.base import Model
|
|
34
|
+
from oxyde.queries.base import SupportsExecute
|
|
35
|
+
from oxyde.queries.typed import (
|
|
36
|
+
FlatValuesListQuery,
|
|
37
|
+
ValuesListQuery,
|
|
38
|
+
ValuesQuery,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class QueryManager(Generic[TModel]):
|
|
46
|
+
"""Manager that provides Query access for a model.
|
|
47
|
+
|
|
48
|
+
All methods delegate to Query - this is just a factory/proxy.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, model_class: type[TModel]) -> None:
|
|
52
|
+
self.model_class = model_class
|
|
53
|
+
|
|
54
|
+
def _query(self) -> Query[TModel]:
|
|
55
|
+
"""Create a new Query for this model."""
|
|
56
|
+
return Query(self.model_class)
|
|
57
|
+
|
|
58
|
+
# --- Query building methods (return Query) ---
|
|
59
|
+
|
|
60
|
+
def query(self) -> Query[TModel]:
|
|
61
|
+
"""Return a Query builder for this model."""
|
|
62
|
+
return self._query()
|
|
63
|
+
|
|
64
|
+
def filter(self, *args: Any, **kwargs: Any) -> Query[TModel]:
|
|
65
|
+
"""Filter by Q-expressions or field lookups."""
|
|
66
|
+
return self._query().filter(*args, **kwargs)
|
|
67
|
+
|
|
68
|
+
def exclude(self, *args: Any, **kwargs: Any) -> Query[TModel]:
|
|
69
|
+
"""Exclude rows matching conditions (NOT filter)."""
|
|
70
|
+
return self._query().exclude(*args, **kwargs)
|
|
71
|
+
|
|
72
|
+
def values(self, *fields: str) -> ValuesQuery[TModel]:
|
|
73
|
+
"""Return dicts instead of models."""
|
|
74
|
+
return self._query().values(*fields)
|
|
75
|
+
|
|
76
|
+
@overload
|
|
77
|
+
def values_list(
|
|
78
|
+
self, field: str, /, *, flat: Literal[True]
|
|
79
|
+
) -> FlatValuesListQuery[TModel]: ...
|
|
80
|
+
|
|
81
|
+
@overload
|
|
82
|
+
def values_list(
|
|
83
|
+
self, *fields: str, flat: Literal[False] = ...
|
|
84
|
+
) -> ValuesListQuery[TModel]: ...
|
|
85
|
+
|
|
86
|
+
@overload
|
|
87
|
+
def values_list(
|
|
88
|
+
self, *fields: str, flat: bool
|
|
89
|
+
) -> ValuesListQuery[TModel] | FlatValuesListQuery[TModel]: ...
|
|
90
|
+
|
|
91
|
+
def values_list(self, *fields: str, flat: bool = False) -> Any:
|
|
92
|
+
"""Return tuples/lists instead of models."""
|
|
93
|
+
return self._query().values_list(*fields, flat=flat)
|
|
94
|
+
|
|
95
|
+
def distinct(self, distinct: bool = True) -> Query[TModel]:
|
|
96
|
+
"""Add DISTINCT to query."""
|
|
97
|
+
return self._query().distinct(distinct)
|
|
98
|
+
|
|
99
|
+
def join(self, *paths: str) -> Query[TModel]:
|
|
100
|
+
"""Eager load relations via JOIN."""
|
|
101
|
+
return self._query().join(*paths)
|
|
102
|
+
|
|
103
|
+
def prefetch(self, *paths: str) -> Query[TModel]:
|
|
104
|
+
"""Prefetch relations in separate queries."""
|
|
105
|
+
return self._query().prefetch(*paths)
|
|
106
|
+
|
|
107
|
+
def for_update(self) -> Query[TModel]:
|
|
108
|
+
"""Add FOR UPDATE lock."""
|
|
109
|
+
return self._query().for_update()
|
|
110
|
+
|
|
111
|
+
def for_share(self) -> Query[TModel]:
|
|
112
|
+
"""Add FOR SHARE lock."""
|
|
113
|
+
return self._query().for_share()
|
|
114
|
+
|
|
115
|
+
def order_by(self, *fields: str) -> Query[TModel]:
|
|
116
|
+
"""Order results by fields. Prefix with '-' for descending."""
|
|
117
|
+
return self._query().order_by(*fields)
|
|
118
|
+
|
|
119
|
+
def limit(self, value: int) -> Query[TModel]:
|
|
120
|
+
"""Limit number of results."""
|
|
121
|
+
return self._query().limit(value)
|
|
122
|
+
|
|
123
|
+
def offset(self, value: int) -> Query[TModel]:
|
|
124
|
+
"""Skip first N results."""
|
|
125
|
+
return self._query().offset(value)
|
|
126
|
+
|
|
127
|
+
def annotate(self, **annotations: Any) -> Query[TModel]:
|
|
128
|
+
"""Add aggregate annotations."""
|
|
129
|
+
return self._query().annotate(**annotations)
|
|
130
|
+
|
|
131
|
+
# --- Execution methods (delegate to Query) ---
|
|
132
|
+
|
|
133
|
+
@overload
|
|
134
|
+
def all(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
using: str | None = ...,
|
|
138
|
+
client: SupportsExecute | None = ...,
|
|
139
|
+
mode: Literal["models"] = ...,
|
|
140
|
+
) -> Coroutine[Any, Any, list[TModel]]: ...
|
|
141
|
+
|
|
142
|
+
@overload
|
|
143
|
+
def all(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
using: str | None = ...,
|
|
147
|
+
client: SupportsExecute | None = ...,
|
|
148
|
+
mode: str,
|
|
149
|
+
) -> Coroutine[Any, Any, Any]: ...
|
|
150
|
+
|
|
151
|
+
def all(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
using: str | None = None,
|
|
155
|
+
client: SupportsExecute | None = None,
|
|
156
|
+
mode: str = "models",
|
|
157
|
+
) -> Coroutine[Any, Any, Any]:
|
|
158
|
+
"""Execute query and return all results.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
using: Database alias
|
|
162
|
+
client: Optional database client
|
|
163
|
+
mode: Result mode ("models", "dict", "list", "msgpack")
|
|
164
|
+
"""
|
|
165
|
+
q: Query[TModel] = self._query()
|
|
166
|
+
if mode == "dict":
|
|
167
|
+
q = q.values()
|
|
168
|
+
elif mode == "list":
|
|
169
|
+
q = q.values_list()
|
|
170
|
+
elif mode == "msgpack":
|
|
171
|
+
q._result_mode = "msgpack"
|
|
172
|
+
return q.all(using=using, client=client)
|
|
173
|
+
|
|
174
|
+
async def first(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
using: str | None = None,
|
|
178
|
+
client: SupportsExecute | None = None,
|
|
179
|
+
) -> TModel | None:
|
|
180
|
+
"""Return first result or None."""
|
|
181
|
+
return await self._query().first(using=using, client=client)
|
|
182
|
+
|
|
183
|
+
async def last(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
using: str | None = None,
|
|
187
|
+
client: SupportsExecute | None = None,
|
|
188
|
+
) -> TModel | None:
|
|
189
|
+
"""Return last result or None."""
|
|
190
|
+
return await self._query().last(using=using, client=client)
|
|
191
|
+
|
|
192
|
+
async def get(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
using: str | None = None,
|
|
196
|
+
client: SupportsExecute | None = None,
|
|
197
|
+
**filters: Any,
|
|
198
|
+
) -> TModel:
|
|
199
|
+
"""Return exactly one result. Raises NotFoundError/MultipleObjectsReturned."""
|
|
200
|
+
q = self._query()
|
|
201
|
+
if filters:
|
|
202
|
+
q = q.filter(**filters)
|
|
203
|
+
return await q.get(using=using, client=client)
|
|
204
|
+
|
|
205
|
+
async def get_or_none(
|
|
206
|
+
self,
|
|
207
|
+
*,
|
|
208
|
+
using: str | None = None,
|
|
209
|
+
client: SupportsExecute | None = None,
|
|
210
|
+
**filters: Any,
|
|
211
|
+
) -> TModel | None:
|
|
212
|
+
"""Return one result or None. Raises MultipleObjectsReturned if many."""
|
|
213
|
+
q = self._query()
|
|
214
|
+
if filters:
|
|
215
|
+
q = q.filter(**filters)
|
|
216
|
+
return await q.get_or_none(using=using, client=client)
|
|
217
|
+
|
|
218
|
+
async def get_or_create(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
defaults: dict[str, Any] | None = None,
|
|
222
|
+
using: str | None = None,
|
|
223
|
+
client: SupportsExecute | None = None,
|
|
224
|
+
**filters: Any,
|
|
225
|
+
) -> tuple[TModel, bool]:
|
|
226
|
+
"""Get existing object or create a new one.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
defaults: Field values to use when creating (not for lookup)
|
|
230
|
+
using: Database alias
|
|
231
|
+
client: Optional database client
|
|
232
|
+
**filters: Lookup conditions for finding existing object
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (instance, created) where created is True if new object was made
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
obj = await self.get(using=using, client=client, **filters)
|
|
239
|
+
return obj, False
|
|
240
|
+
except NotFoundError:
|
|
241
|
+
create_data = _derive_create_data(filters, defaults)
|
|
242
|
+
try:
|
|
243
|
+
obj = await self.create(using=using, client=client, **create_data)
|
|
244
|
+
return obj, True
|
|
245
|
+
except IntegrityError:
|
|
246
|
+
obj = await self.get(using=using, client=client, **filters)
|
|
247
|
+
return obj, False
|
|
248
|
+
|
|
249
|
+
async def exists(
|
|
250
|
+
self,
|
|
251
|
+
*,
|
|
252
|
+
using: str | None = None,
|
|
253
|
+
client: SupportsExecute | None = None,
|
|
254
|
+
) -> bool:
|
|
255
|
+
"""Check if any records match."""
|
|
256
|
+
return await self._query().exists(using=using, client=client)
|
|
257
|
+
|
|
258
|
+
async def count(
|
|
259
|
+
self,
|
|
260
|
+
*,
|
|
261
|
+
using: str | None = None,
|
|
262
|
+
client: SupportsExecute | None = None,
|
|
263
|
+
) -> int:
|
|
264
|
+
"""Count all records using SQL COUNT(*)."""
|
|
265
|
+
return await self._query().count(using=using, client=client)
|
|
266
|
+
|
|
267
|
+
async def sum(
|
|
268
|
+
self,
|
|
269
|
+
field: str,
|
|
270
|
+
*,
|
|
271
|
+
using: str | None = None,
|
|
272
|
+
client: SupportsExecute | None = None,
|
|
273
|
+
) -> Any:
|
|
274
|
+
"""Calculate sum of field values."""
|
|
275
|
+
return await self._query().sum(field, using=using, client=client)
|
|
276
|
+
|
|
277
|
+
async def avg(
|
|
278
|
+
self,
|
|
279
|
+
field: str,
|
|
280
|
+
*,
|
|
281
|
+
using: str | None = None,
|
|
282
|
+
client: SupportsExecute | None = None,
|
|
283
|
+
) -> Any:
|
|
284
|
+
"""Calculate average of field values."""
|
|
285
|
+
return await self._query().avg(field, using=using, client=client)
|
|
286
|
+
|
|
287
|
+
async def max(
|
|
288
|
+
self,
|
|
289
|
+
field: str,
|
|
290
|
+
*,
|
|
291
|
+
using: str | None = None,
|
|
292
|
+
client: SupportsExecute | None = None,
|
|
293
|
+
) -> Any:
|
|
294
|
+
"""Get maximum field value."""
|
|
295
|
+
return await self._query().max(field, using=using, client=client)
|
|
296
|
+
|
|
297
|
+
async def min(
|
|
298
|
+
self,
|
|
299
|
+
field: str,
|
|
300
|
+
*,
|
|
301
|
+
using: str | None = None,
|
|
302
|
+
client: SupportsExecute | None = None,
|
|
303
|
+
) -> Any:
|
|
304
|
+
"""Get minimum field value."""
|
|
305
|
+
return await self._query().min(field, using=using, client=client)
|
|
306
|
+
|
|
307
|
+
# --- Mutation methods (delegate to Query) ---
|
|
308
|
+
|
|
309
|
+
async def create(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
instance: TModel | None = None,
|
|
313
|
+
using: str | None = None,
|
|
314
|
+
client: SupportsExecute | None = None,
|
|
315
|
+
_skip_hooks: bool = False,
|
|
316
|
+
**data: Any,
|
|
317
|
+
) -> TModel:
|
|
318
|
+
"""Create a new record in the database."""
|
|
319
|
+
return await self._query().create(
|
|
320
|
+
instance=instance,
|
|
321
|
+
using=using,
|
|
322
|
+
client=client,
|
|
323
|
+
_skip_hooks=_skip_hooks,
|
|
324
|
+
**data,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def bulk_create(
|
|
328
|
+
self,
|
|
329
|
+
objects: Iterable[TModel | dict[str, Any]],
|
|
330
|
+
*,
|
|
331
|
+
using: str | None = None,
|
|
332
|
+
client: SupportsExecute | None = None,
|
|
333
|
+
batch_size: int | None = None,
|
|
334
|
+
) -> list[TModel]:
|
|
335
|
+
"""Bulk insert multiple objects efficiently."""
|
|
336
|
+
return await self._query().bulk_create(
|
|
337
|
+
objects,
|
|
338
|
+
using=using,
|
|
339
|
+
client=client,
|
|
340
|
+
batch_size=batch_size,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
async def bulk_update(
|
|
344
|
+
self,
|
|
345
|
+
objects: Iterable[TModel],
|
|
346
|
+
fields: Iterable[str],
|
|
347
|
+
*,
|
|
348
|
+
using: str | None = None,
|
|
349
|
+
client: SupportsExecute | None = None,
|
|
350
|
+
) -> int:
|
|
351
|
+
"""Bulk update multiple objects with CASE WHEN."""
|
|
352
|
+
return await self._query().bulk_update(
|
|
353
|
+
objects,
|
|
354
|
+
fields,
|
|
355
|
+
using=using,
|
|
356
|
+
client=client,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
async def update_or_create(
|
|
360
|
+
self,
|
|
361
|
+
*,
|
|
362
|
+
defaults: dict[str, Any] | None = None,
|
|
363
|
+
using: str | None = None,
|
|
364
|
+
client: SupportsExecute | None = None,
|
|
365
|
+
**filters: Any,
|
|
366
|
+
) -> tuple[TModel, bool]:
|
|
367
|
+
"""Get existing object and update it, or create it if it does not exist.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
defaults: Field values to use when creating or updating
|
|
371
|
+
using: Database alias
|
|
372
|
+
client: Optional database client
|
|
373
|
+
**filters: Lookup conditions for finding existing object
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Tuple of (instance, created) where created is True if a new
|
|
377
|
+
object was made.
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
obj = await self.get(using=using, client=client, **filters)
|
|
381
|
+
except NotFoundError:
|
|
382
|
+
create_data = _derive_create_data(filters, defaults)
|
|
383
|
+
try:
|
|
384
|
+
obj = await self.create(using=using, client=client, **create_data)
|
|
385
|
+
return obj, True
|
|
386
|
+
except IntegrityError:
|
|
387
|
+
obj = await self.get(using=using, client=client, **filters)
|
|
388
|
+
|
|
389
|
+
if not defaults:
|
|
390
|
+
return obj, False
|
|
391
|
+
|
|
392
|
+
for key, value in defaults.items():
|
|
393
|
+
setattr(obj, key, value)
|
|
394
|
+
|
|
395
|
+
saved_obj = await obj.save(
|
|
396
|
+
client=client,
|
|
397
|
+
using=using,
|
|
398
|
+
update_fields=defaults.keys(),
|
|
399
|
+
)
|
|
400
|
+
return saved_obj, False
|
|
401
|
+
|
|
402
|
+
async def upsert(self, *args: Any, **kwargs: Any) -> Any:
|
|
403
|
+
"""Not implemented yet."""
|
|
404
|
+
raise ManagerError("upsert() is not implemented yet")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
__all__ = [
|
|
408
|
+
"QueryManager",
|
|
409
|
+
]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Query mixins providing modular functionality for Query class.
|
|
2
|
+
|
|
3
|
+
The Query class inherits from all these mixins, each providing a specific
|
|
4
|
+
category of query operations. This keeps the code organized and testable.
|
|
5
|
+
|
|
6
|
+
Mixins:
|
|
7
|
+
FilteringMixin:
|
|
8
|
+
filter(), exclude() - WHERE clause building
|
|
9
|
+
|
|
10
|
+
PaginationMixin:
|
|
11
|
+
select() - choose specific columns
|
|
12
|
+
limit(), offset() - result pagination
|
|
13
|
+
order_by() - ORDER BY clause
|
|
14
|
+
distinct() - DISTINCT modifier
|
|
15
|
+
values(), values_list() - return dicts/tuples instead of models
|
|
16
|
+
__getitem__ - slice syntax for limit/offset
|
|
17
|
+
|
|
18
|
+
JoiningMixin:
|
|
19
|
+
join() - eager loading with SQL JOINs
|
|
20
|
+
prefetch() - prefetch related objects (separate queries)
|
|
21
|
+
|
|
22
|
+
AggregationMixin:
|
|
23
|
+
annotate() - add computed columns (COUNT, SUM, etc.)
|
|
24
|
+
group_by() - GROUP BY clause
|
|
25
|
+
having() - HAVING clause
|
|
26
|
+
count(), sum(), avg(), max(), min() - terminal aggregates
|
|
27
|
+
|
|
28
|
+
ExecutionMixin:
|
|
29
|
+
fetch_all(), fetch_one() - raw execution returning dicts
|
|
30
|
+
fetch_rows() - raw execution returning list of tuples
|
|
31
|
+
fetch_models() - execution returning model instances
|
|
32
|
+
fetch_msgpack() - execution returning raw MessagePack bytes
|
|
33
|
+
all(), first(), last(), exists() - convenience methods
|
|
34
|
+
|
|
35
|
+
MutationMixin:
|
|
36
|
+
create(), bulk_create() - INSERT operations
|
|
37
|
+
update(), bulk_update() - UPDATE operations
|
|
38
|
+
delete() - DELETE from SELECT query
|
|
39
|
+
increment() - atomic increment via F()
|
|
40
|
+
|
|
41
|
+
DebugMixin:
|
|
42
|
+
sql() - get generated SQL string
|
|
43
|
+
query - property to inspect IR dict
|
|
44
|
+
explain() - get query execution plan
|
|
45
|
+
union(), union_all() - combine queries
|
|
46
|
+
|
|
47
|
+
Design:
|
|
48
|
+
Each mixin expects certain attributes on self (from Query.__init__).
|
|
49
|
+
Mixins use _clone() to maintain immutability.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from oxyde.queries.mixins.aggregation import AggregationMixin
|
|
53
|
+
from oxyde.queries.mixins.debug import DebugMixin
|
|
54
|
+
from oxyde.queries.mixins.execution import ExecutionMixin
|
|
55
|
+
from oxyde.queries.mixins.filtering import FilteringMixin
|
|
56
|
+
from oxyde.queries.mixins.joining import JoiningMixin
|
|
57
|
+
from oxyde.queries.mixins.mutation import MutationMixin
|
|
58
|
+
from oxyde.queries.mixins.pagination import PaginationMixin
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"FilteringMixin",
|
|
62
|
+
"PaginationMixin",
|
|
63
|
+
"JoiningMixin",
|
|
64
|
+
"AggregationMixin",
|
|
65
|
+
"ExecutionMixin",
|
|
66
|
+
"MutationMixin",
|
|
67
|
+
"DebugMixin",
|
|
68
|
+
]
|