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,144 @@
|
|
|
1
|
+
"""Filtering mixin for query building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
|
|
9
|
+
from oxyde.core import ir
|
|
10
|
+
from oxyde.queries.q import Q
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from oxyde.models.base import Model
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FilteringMixin(Generic[TModel]):
|
|
20
|
+
"""Mixin providing filtering capabilities."""
|
|
21
|
+
|
|
22
|
+
# These attributes are defined in the base Query class
|
|
23
|
+
_filter_tree: ir.FilterNode | None
|
|
24
|
+
model_class: type[TModel]
|
|
25
|
+
|
|
26
|
+
def _clone(self) -> Self:
|
|
27
|
+
"""Must be implemented by the main Query class."""
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def filter(self, *args: Any, **kwargs: Any) -> Self:
|
|
31
|
+
"""
|
|
32
|
+
Filter by Q-expressions or field lookups.
|
|
33
|
+
|
|
34
|
+
Supports filtering on related model fields via FK traversal:
|
|
35
|
+
.filter(user__age__gte=18) # Filter by joined user.age
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
*args: Q expression objects for complex conditions (AND/OR/NOT)
|
|
39
|
+
**kwargs: Field lookups (e.g., name__icontains="foo", age__gte=18,
|
|
40
|
+
user__name="Alice")
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Query with filter conditions applied
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
.filter(name="Alice")
|
|
47
|
+
.filter(age__gte=18, status="active")
|
|
48
|
+
.filter(Q(age__gte=18) | Q(premium=True))
|
|
49
|
+
.filter(Q(name="Bob"), age__lt=30)
|
|
50
|
+
.filter(user__age__gte=18) # FK traversal
|
|
51
|
+
"""
|
|
52
|
+
clone = self._clone()
|
|
53
|
+
conditions: list[ir.FilterNode] = []
|
|
54
|
+
|
|
55
|
+
# Process Q-expressions from args
|
|
56
|
+
for arg in args:
|
|
57
|
+
if isinstance(arg, Q):
|
|
58
|
+
node = arg.to_filter_node(self.model_class, clone)
|
|
59
|
+
if node:
|
|
60
|
+
conditions.append(node)
|
|
61
|
+
else:
|
|
62
|
+
raise TypeError(
|
|
63
|
+
f"filter() positional args must be Q objects, got {type(arg)}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Process kwargs
|
|
67
|
+
if kwargs:
|
|
68
|
+
q_from_kwargs = Q(**kwargs)
|
|
69
|
+
node = q_from_kwargs.to_filter_node(self.model_class, clone)
|
|
70
|
+
if node:
|
|
71
|
+
conditions.append(node)
|
|
72
|
+
|
|
73
|
+
if not conditions:
|
|
74
|
+
return clone
|
|
75
|
+
|
|
76
|
+
# Merge with existing filter_tree
|
|
77
|
+
if clone._filter_tree:
|
|
78
|
+
conditions.insert(0, clone._filter_tree)
|
|
79
|
+
|
|
80
|
+
if len(conditions) == 1:
|
|
81
|
+
clone._filter_tree = conditions[0]
|
|
82
|
+
else:
|
|
83
|
+
clone._filter_tree = ir.filter_and(*conditions)
|
|
84
|
+
|
|
85
|
+
return clone
|
|
86
|
+
|
|
87
|
+
def exclude(self, *args: Any, **kwargs: Any) -> Self:
|
|
88
|
+
"""
|
|
89
|
+
Add negated filter conditions.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
*args: Q expression objects to negate
|
|
93
|
+
**kwargs: Field lookups to negate
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Cloned query with negated filter
|
|
97
|
+
|
|
98
|
+
Examples:
|
|
99
|
+
.exclude(status="banned")
|
|
100
|
+
.exclude(Q(age__lt=18) | Q(age__gt=65))
|
|
101
|
+
.exclude(user__age__lt=18) # FK traversal
|
|
102
|
+
"""
|
|
103
|
+
clone = self._clone()
|
|
104
|
+
conditions_to_negate: list[ir.FilterNode] = []
|
|
105
|
+
|
|
106
|
+
# Process Q-expressions from args
|
|
107
|
+
for arg in args:
|
|
108
|
+
if isinstance(arg, Q):
|
|
109
|
+
node = arg.to_filter_node(self.model_class, clone)
|
|
110
|
+
if node:
|
|
111
|
+
conditions_to_negate.append(node)
|
|
112
|
+
else:
|
|
113
|
+
raise TypeError(
|
|
114
|
+
f"exclude() positional args must be Q objects, got {type(arg)}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Process kwargs
|
|
118
|
+
if kwargs:
|
|
119
|
+
q_from_kwargs = Q(**kwargs)
|
|
120
|
+
node = q_from_kwargs.to_filter_node(self.model_class, clone)
|
|
121
|
+
if node:
|
|
122
|
+
conditions_to_negate.append(node)
|
|
123
|
+
|
|
124
|
+
if not conditions_to_negate:
|
|
125
|
+
return clone
|
|
126
|
+
|
|
127
|
+
# Combine conditions with AND, then negate
|
|
128
|
+
if len(conditions_to_negate) == 1:
|
|
129
|
+
negated = ir.filter_not(conditions_to_negate[0])
|
|
130
|
+
else:
|
|
131
|
+
combined = ir.filter_and(*conditions_to_negate)
|
|
132
|
+
negated = ir.filter_not(combined)
|
|
133
|
+
|
|
134
|
+
# Merge negated filter into clone
|
|
135
|
+
if clone._filter_tree:
|
|
136
|
+
clone._filter_tree = ir.filter_and(clone._filter_tree, negated)
|
|
137
|
+
else:
|
|
138
|
+
clone._filter_tree = negated
|
|
139
|
+
|
|
140
|
+
return clone
|
|
141
|
+
|
|
142
|
+
def _build_filter_tree(self) -> ir.FilterNode | None:
|
|
143
|
+
"""Return the filter tree."""
|
|
144
|
+
return self._filter_tree
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Joining mixin for query building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from oxyde.exceptions import FieldLookupError
|
|
11
|
+
from oxyde.queries.base import (
|
|
12
|
+
_collect_model_columns,
|
|
13
|
+
_primary_key_meta,
|
|
14
|
+
_resolve_registered_model,
|
|
15
|
+
)
|
|
16
|
+
from oxyde.queries.joins import _JoinDescriptor
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from oxyde.models.base import Model
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
TModel = TypeVar("TModel", bound="Model")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JoiningMixin(Generic[TModel]):
|
|
26
|
+
"""Mixin providing join and prefetch capabilities."""
|
|
27
|
+
|
|
28
|
+
# These attributes are defined in the base Query class
|
|
29
|
+
model_class: type[TModel]
|
|
30
|
+
_join_specs: list[_JoinDescriptor]
|
|
31
|
+
_prefetch_paths: list[str]
|
|
32
|
+
|
|
33
|
+
def _clone(self) -> Self:
|
|
34
|
+
"""Must be implemented by the main Query class."""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def join(self, *paths: str) -> Self:
|
|
38
|
+
"""
|
|
39
|
+
Add LEFT JOIN for related models.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
*paths: Relation paths (e.g., "author", "author__profile")
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
Post.objects.join("author")
|
|
46
|
+
Post.objects.join("author", "category")
|
|
47
|
+
"""
|
|
48
|
+
if not paths:
|
|
49
|
+
raise ValueError("join() requires at least one relation path")
|
|
50
|
+
clone = self._clone()
|
|
51
|
+
for path in paths:
|
|
52
|
+
clone._add_join_path(path)
|
|
53
|
+
return clone
|
|
54
|
+
|
|
55
|
+
def prefetch(self, *paths: str) -> Self:
|
|
56
|
+
"""
|
|
57
|
+
Prefetch related objects (separate queries).
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
*paths: Relation paths for one-to-many relations
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
Author.objects.prefetch("posts")
|
|
64
|
+
"""
|
|
65
|
+
if not paths:
|
|
66
|
+
raise ValueError("prefetch() requires at least one relation path")
|
|
67
|
+
clone = self._clone()
|
|
68
|
+
for path in paths:
|
|
69
|
+
if path not in clone._prefetch_paths:
|
|
70
|
+
clone._prefetch_paths.append(path)
|
|
71
|
+
return clone
|
|
72
|
+
|
|
73
|
+
def _column_for_field(self, field: str) -> str:
|
|
74
|
+
"""Get database column name for a field."""
|
|
75
|
+
meta = self.model_class._db_meta.field_metadata.get(field)
|
|
76
|
+
if meta is not None:
|
|
77
|
+
return meta.db_column
|
|
78
|
+
return field
|
|
79
|
+
|
|
80
|
+
def _column_mappings_for_fields(self, fields: Iterable[str]) -> dict[str, str]:
|
|
81
|
+
"""Get column mappings for fields where column differs from field name."""
|
|
82
|
+
mappings: dict[str, str] = {}
|
|
83
|
+
for field in fields:
|
|
84
|
+
column = self._column_for_field(field)
|
|
85
|
+
if column != field:
|
|
86
|
+
mappings[field] = column
|
|
87
|
+
return mappings
|
|
88
|
+
|
|
89
|
+
def _add_join_path(self, path: str) -> None:
|
|
90
|
+
"""Add join descriptors for a relation path."""
|
|
91
|
+
descriptors = self._compute_join_descriptors(path)
|
|
92
|
+
existing = {spec.path for spec in self._join_specs}
|
|
93
|
+
for descriptor in descriptors:
|
|
94
|
+
if descriptor.path not in existing:
|
|
95
|
+
self._join_specs.append(descriptor)
|
|
96
|
+
existing.add(descriptor.path)
|
|
97
|
+
|
|
98
|
+
def _compute_join_descriptors(self, path: str) -> list[_JoinDescriptor]:
|
|
99
|
+
"""Compute join descriptors for a relation path."""
|
|
100
|
+
if not path:
|
|
101
|
+
raise ValueError("join() path must be non-empty")
|
|
102
|
+
segments = path.split("__")
|
|
103
|
+
current_model: type[Model] = self.model_class
|
|
104
|
+
parent_path: str | None = None
|
|
105
|
+
parent_alias: str | None = None
|
|
106
|
+
descriptors: list[_JoinDescriptor] = []
|
|
107
|
+
prefix: list[str] = []
|
|
108
|
+
|
|
109
|
+
for segment in segments:
|
|
110
|
+
column_meta = current_model._db_meta.field_metadata.get(segment)
|
|
111
|
+
if column_meta is None or column_meta.foreign_key is None:
|
|
112
|
+
raise FieldLookupError(
|
|
113
|
+
f"{current_model.__name__}.{segment} is not a joinable relation"
|
|
114
|
+
)
|
|
115
|
+
target_model = _resolve_registered_model(column_meta.foreign_key.target)
|
|
116
|
+
prefix.append(segment)
|
|
117
|
+
path_key = "__".join(prefix)
|
|
118
|
+
alias = path_key.replace("__", "_")
|
|
119
|
+
pk_meta = _primary_key_meta(target_model)
|
|
120
|
+
descriptor = _JoinDescriptor(
|
|
121
|
+
path=path_key,
|
|
122
|
+
alias=alias,
|
|
123
|
+
result_prefix=path_key,
|
|
124
|
+
parent_path=parent_path,
|
|
125
|
+
parent_alias=parent_alias,
|
|
126
|
+
attr_name=segment,
|
|
127
|
+
source_column=column_meta.foreign_key.column_name,
|
|
128
|
+
target_column=pk_meta.db_column,
|
|
129
|
+
columns=_collect_model_columns(target_model),
|
|
130
|
+
target_model=target_model,
|
|
131
|
+
parent_model=current_model,
|
|
132
|
+
nullable=column_meta.nullable,
|
|
133
|
+
)
|
|
134
|
+
descriptors.append(descriptor)
|
|
135
|
+
current_model = target_model
|
|
136
|
+
parent_path = path_key
|
|
137
|
+
parent_alias = alias
|
|
138
|
+
|
|
139
|
+
return descriptors
|
|
140
|
+
|
|
141
|
+
def _join_specs_to_ir(self) -> list[dict[str, Any]]:
|
|
142
|
+
"""Convert join specs to IR format."""
|
|
143
|
+
specs: list[dict[str, Any]] = []
|
|
144
|
+
for descriptor in self._join_specs:
|
|
145
|
+
specs.append(
|
|
146
|
+
{
|
|
147
|
+
"path": descriptor.path,
|
|
148
|
+
"alias": descriptor.alias,
|
|
149
|
+
"parent": descriptor.parent_alias,
|
|
150
|
+
"table": descriptor.target_model.get_table_name(),
|
|
151
|
+
"source_column": descriptor.source_column,
|
|
152
|
+
"target_column": descriptor.target_column,
|
|
153
|
+
"result_prefix": descriptor.result_prefix,
|
|
154
|
+
"columns": [
|
|
155
|
+
{"field": field, "column": column}
|
|
156
|
+
for field, column in descriptor.columns
|
|
157
|
+
],
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return specs
|