async-orm-kit 1.0.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 (92) hide show
  1. async_orm_kit/__init__.py +151 -0
  2. async_orm_kit/_logging.py +58 -0
  3. async_orm_kit/_version.py +1 -0
  4. async_orm_kit/config.py +31 -0
  5. async_orm_kit/exceptions.py +353 -0
  6. async_orm_kit/integrations/__init__.py +15 -0
  7. async_orm_kit/integrations/alembic.py +56 -0
  8. async_orm_kit/integrations/fastapi.py +134 -0
  9. async_orm_kit/integrations/storage.py +213 -0
  10. async_orm_kit/manager/__init__.py +3 -0
  11. async_orm_kit/manager/manager.py +568 -0
  12. async_orm_kit/models/__init__.py +3 -0
  13. async_orm_kit/models/base.py +568 -0
  14. async_orm_kit/models/fields/__init__.py +23 -0
  15. async_orm_kit/models/fields/exceptions.py +10 -0
  16. async_orm_kit/models/fields/typed/__init__.py +11 -0
  17. async_orm_kit/models/fields/typed/base.py +155 -0
  18. async_orm_kit/models/fields/typed/file.py +697 -0
  19. async_orm_kit/models/fields/typed/string.py +38 -0
  20. async_orm_kit/models/fields/validators.py +110 -0
  21. async_orm_kit/models/meta.py +181 -0
  22. async_orm_kit/models/mixins.py +19 -0
  23. async_orm_kit/observers/__init__.py +4 -0
  24. async_orm_kit/observers/mixin.py +26 -0
  25. async_orm_kit/observers/model_observer.py +20 -0
  26. async_orm_kit/observers/queryset_observer.py +38 -0
  27. async_orm_kit/py.typed +0 -0
  28. async_orm_kit/query/__init__.py +0 -0
  29. async_orm_kit/query/aggregations/__init__.py +8 -0
  30. async_orm_kit/query/aggregations/avg.py +71 -0
  31. async_orm_kit/query/aggregations/base.py +317 -0
  32. async_orm_kit/query/aggregations/count.py +115 -0
  33. async_orm_kit/query/aggregations/max.py +76 -0
  34. async_orm_kit/query/aggregations/min.py +76 -0
  35. async_orm_kit/query/aggregations/sum.py +73 -0
  36. async_orm_kit/query/compiler.py +574 -0
  37. async_orm_kit/query/debugger.py +269 -0
  38. async_orm_kit/query/expressions/__init__.py +62 -0
  39. async_orm_kit/query/expressions/analysis.py +56 -0
  40. async_orm_kit/query/expressions/case.py +321 -0
  41. async_orm_kit/query/expressions/collections.py +251 -0
  42. async_orm_kit/query/expressions/f.py +362 -0
  43. async_orm_kit/query/expressions/functions.py +202 -0
  44. async_orm_kit/query/expressions/subquery.py +206 -0
  45. async_orm_kit/query/expressions/value.py +101 -0
  46. async_orm_kit/query/expressions/window.py +95 -0
  47. async_orm_kit/query/lookups/__init__.py +121 -0
  48. async_orm_kit/query/lookups/conditions.py +315 -0
  49. async_orm_kit/query/lookups/correlated.py +86 -0
  50. async_orm_kit/query/lookups/json_paths.py +167 -0
  51. async_orm_kit/query/lookups/parser.py +276 -0
  52. async_orm_kit/query/lookups/resolver/__init__.py +5 -0
  53. async_orm_kit/query/lookups/resolver/context.py +150 -0
  54. async_orm_kit/query/lookups/resolver/resolver.py +572 -0
  55. async_orm_kit/query/lookups/resolver/types.py +150 -0
  56. async_orm_kit/query/mixins/__init__.py +0 -0
  57. async_orm_kit/query/mixins/combinable.py +376 -0
  58. async_orm_kit/query/mixins/orderable.py +208 -0
  59. async_orm_kit/query/observability.py +346 -0
  60. async_orm_kit/query/planning/__init__.py +0 -0
  61. async_orm_kit/query/planning/directives.py +117 -0
  62. async_orm_kit/query/planning/factory.py +40 -0
  63. async_orm_kit/query/planning/passes/__init__.py +15 -0
  64. async_orm_kit/query/planning/passes/base.py +47 -0
  65. async_orm_kit/query/planning/passes/cartesian_detection.py +74 -0
  66. async_orm_kit/query/planning/passes/depth_splitting.py +78 -0
  67. async_orm_kit/query/planning/passes/join_promotion.py +81 -0
  68. async_orm_kit/query/planning/passes/path_conflicts.py +103 -0
  69. async_orm_kit/query/planning/passes/redundant_path.py +67 -0
  70. async_orm_kit/query/planning/passes/strategy_selection.py +49 -0
  71. async_orm_kit/query/planning/planner.py +160 -0
  72. async_orm_kit/query/planning/protocols.py +78 -0
  73. async_orm_kit/query/postgres/__init__.py +0 -0
  74. async_orm_kit/query/postgres/optimizer.py +189 -0
  75. async_orm_kit/query/q.py +596 -0
  76. async_orm_kit/query/relations/__init__.py +0 -0
  77. async_orm_kit/query/relations/enums.py +16 -0
  78. async_orm_kit/query/relations/graph.py +908 -0
  79. async_orm_kit/query/relations/node.py +421 -0
  80. async_orm_kit/queryset/__init__.py +4 -0
  81. async_orm_kit/queryset/builder.py +1259 -0
  82. async_orm_kit/queryset/compound.py +379 -0
  83. async_orm_kit/queryset/executor.py +718 -0
  84. async_orm_kit/queryset/queryset.py +1726 -0
  85. async_orm_kit/queryset/state.py +164 -0
  86. async_orm_kit/queryset/validators.py +361 -0
  87. async_orm_kit/queryset/writer.py +755 -0
  88. async_orm_kit/types.py +23 -0
  89. async_orm_kit-1.0.0.dist-info/METADATA +318 -0
  90. async_orm_kit-1.0.0.dist-info/RECORD +92 -0
  91. async_orm_kit-1.0.0.dist-info/WHEEL +4 -0
  92. async_orm_kit-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,202 @@
1
+ """Typed, parameterized SQL function expressions with a portable core API.
2
+
3
+ User-defined raw function names are intentionally not accepted: each wrapper
4
+ chooses a reviewed SQLAlchemy function/operator and resolves operands through
5
+ the same field resolver as ``F``, ``Case`` and aggregate expressions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from sqlalchemy import cast, extract, func
13
+ from sqlalchemy.sql.elements import ClauseElement
14
+ from sqlalchemy.types import TypeEngine
15
+
16
+ from async_orm_kit.query.lookups.resolver import ResolverContext
17
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
18
+ from async_orm_kit.query.mixins.orderable import OrderableMixin
19
+
20
+
21
+ def _operand(value: Any, field_resolver: Any, context: ResolverContext):
22
+ """Resolve ORM operands, preserving SQLAlchemy SQL and bound literals."""
23
+ from sqlalchemy import literal
24
+
25
+ from async_orm_kit.types import SAFE_SCALAR_TYPES
26
+
27
+ if isinstance(value, CombinableMixin) and hasattr(value, "resolve"):
28
+ return value.resolve(field_resolver=field_resolver, context=context)
29
+ if isinstance(value, ClauseElement):
30
+ return value
31
+ if isinstance(value, SAFE_SCALAR_TYPES):
32
+ return literal(value)
33
+ raise TypeError(f"Unsupported SQL function operand: {type(value).__name__}")
34
+
35
+
36
+ class SQLFunction(CombinableMixin, OrderableMixin):
37
+ """Base class for a fixed, reviewed SQL function with immutable operands."""
38
+
39
+ _name: str
40
+ _arguments: tuple[Any, ...]
41
+ __slots__ = ("_name", "_arguments")
42
+ _allowed = frozenset(
43
+ {"coalesce", "nullif", "lower", "upper", "length", "trim", "round", "abs"}
44
+ )
45
+
46
+ def __init__(self, name: str, *arguments: Any) -> None:
47
+ if name not in self._allowed:
48
+ raise ValueError(f"Unsupported SQL function: {name!r}")
49
+ object.__setattr__(self, "_name", name)
50
+ object.__setattr__(self, "_arguments", tuple(arguments))
51
+
52
+ def __setattr__(self, key, value):
53
+ raise AttributeError("SQL function expressions are immutable")
54
+
55
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
56
+ operands = [_operand(arg, field_resolver, context) for arg in self._arguments]
57
+ return getattr(func, self._name)(*operands)
58
+
59
+
60
+ class Function(CombinableMixin, OrderableMixin):
61
+ """Extensible SQL function with a trusted SQLAlchemy expression factory.
62
+
63
+ A factory receives resolved, parameterized SQLAlchemy operands and must
64
+ return a SQLAlchemy ColumnElement. Calling application-owned functions is
65
+ intentional; the package never interpolates untrusted function names or SQL.
66
+
67
+ Example::
68
+ Function(lambda col: func.reverse(col), F("name"))
69
+ """
70
+
71
+ __slots__ = ("factory", "arguments")
72
+
73
+ def __init__(self, factory: Any, *arguments: Any):
74
+ if not callable(factory):
75
+ raise TypeError("Function factory must be callable")
76
+ self.factory = factory
77
+ self.arguments = tuple(arguments)
78
+
79
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
80
+ from sqlalchemy.sql.elements import ColumnElement
81
+
82
+ values = [_operand(arg, field_resolver, context) for arg in self.arguments]
83
+ result = self.factory(*values)
84
+ if not isinstance(result, ColumnElement):
85
+ raise TypeError("Function factory must return a SQLAlchemy ColumnElement")
86
+ return result
87
+
88
+
89
+ class Coalesce(SQLFunction):
90
+ """Return first non-NULL expression."""
91
+
92
+ def __init__(self, *expressions: Any):
93
+ if len(expressions) < 2:
94
+ raise ValueError("Coalesce requires at least two expressions")
95
+ super().__init__("coalesce", *expressions)
96
+
97
+
98
+ class NullIf(SQLFunction):
99
+ """Return NULL when expressions compare equal."""
100
+
101
+ def __init__(self, first: Any, second: Any):
102
+ super().__init__("nullif", first, second)
103
+
104
+
105
+ class Lower(SQLFunction):
106
+ def __init__(self, expression: Any):
107
+ super().__init__("lower", expression)
108
+
109
+
110
+ class Upper(SQLFunction):
111
+ def __init__(self, expression: Any):
112
+ super().__init__("upper", expression)
113
+
114
+
115
+ class Length(SQLFunction):
116
+ def __init__(self, expression: Any):
117
+ super().__init__("length", expression)
118
+
119
+
120
+ class Trim(SQLFunction):
121
+ def __init__(self, expression: Any):
122
+ super().__init__("trim", expression)
123
+
124
+
125
+ class Round(SQLFunction):
126
+ def __init__(self, expression: Any, precision: Any = 0):
127
+ super().__init__("round", expression, precision)
128
+
129
+
130
+ class Abs(SQLFunction):
131
+ def __init__(self, expression: Any):
132
+ super().__init__("abs", expression)
133
+
134
+
135
+ class Concat(CombinableMixin, OrderableMixin):
136
+ """Concatenate strings using the dialect's string concatenation operator."""
137
+
138
+ __slots__ = ("arguments",)
139
+
140
+ def __init__(self, *expressions: Any):
141
+ if len(expressions) < 2:
142
+ raise ValueError("Concat requires at least two expressions")
143
+ self.arguments = tuple(expressions)
144
+
145
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
146
+ values = [_operand(arg, field_resolver, context) for arg in self.arguments]
147
+ out = values[0]
148
+ for value in values[1:]:
149
+ out = out.concat(value)
150
+ return out
151
+
152
+
153
+ class Cast(CombinableMixin, OrderableMixin):
154
+ """Cast an expression to a validated SQLAlchemy SQL type."""
155
+
156
+ __slots__ = ("expression", "type_")
157
+
158
+ def __init__(self, expression: Any, type_: TypeEngine | type[TypeEngine]):
159
+ if isinstance(type_, type) and issubclass(type_, TypeEngine):
160
+ type_ = type_()
161
+ if not isinstance(type_, TypeEngine):
162
+ raise TypeError("Cast type_ must be a SQLAlchemy TypeEngine")
163
+ self.expression = expression
164
+ self.type_ = type_
165
+
166
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
167
+ return cast(_operand(self.expression, field_resolver, context), self.type_)
168
+
169
+
170
+ class Extract(CombinableMixin, OrderableMixin):
171
+ """Extract a supported date/time part with SQLAlchemy dialect compilation."""
172
+
173
+ __slots__ = ("part", "expression")
174
+ _parts = frozenset(
175
+ {"year", "month", "day", "hour", "minute", "second", "dow", "week", "quarter"}
176
+ )
177
+
178
+ def __init__(self, part: str, expression: Any):
179
+ if part not in self._parts:
180
+ raise ValueError(f"Unsupported Extract part: {part!r}")
181
+ self.part = part
182
+ self.expression = expression
183
+
184
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
185
+ return extract(self.part, _operand(self.expression, field_resolver, context))
186
+
187
+
188
+ __all__ = [
189
+ "Abs",
190
+ "Cast",
191
+ "Coalesce",
192
+ "Concat",
193
+ "Extract",
194
+ "Length",
195
+ "Lower",
196
+ "Function",
197
+ "NullIf",
198
+ "Round",
199
+ "SQLFunction",
200
+ "Trim",
201
+ "Upper",
202
+ ]
@@ -0,0 +1,206 @@
1
+ """Safe correlated subquery expressions for Django-inspired QuerySets.
2
+
3
+ OuterRef markers are never rendered as user-supplied SQL. They are replaced by
4
+ validated, mapped outer columns while a Subquery or Exists expression resolves.
5
+ Outside a containing subquery, unresolved markers are rejected by QueryCompiler.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from sqlalchemy import column, inspect as sa_inspect
11
+ from sqlalchemy.sql import visitors
12
+
13
+ from async_orm_kit.exceptions import QueryStateError
14
+ from async_orm_kit.query.lookups.resolver import ResolverContext
15
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
16
+ from async_orm_kit.query.mixins.orderable import OrderableMixin
17
+
18
+ _OUTER_REF = "_async_orm_kit_outer_ref"
19
+
20
+
21
+ class OuterRef(CombinableMixin, OrderableMixin):
22
+ """Reference a mapped field on the *containing* queryset's model.
23
+
24
+ Example::
25
+
26
+ posts = Post.objects.filter(session, author_id=OuterRef('id'))
27
+ authors = Author.objects.annotate(session, newest=Subquery(
28
+ posts.order_by('-id').values_list('title', flat=True)[:1]
29
+ ))
30
+ """
31
+
32
+ name: str
33
+ depth: int
34
+ __slots__ = ("name", "depth")
35
+
36
+ def __init__(self, name: str | OuterRef):
37
+ if isinstance(name, OuterRef):
38
+ # Django-style OuterRef(OuterRef("id")) skips the immediate
39
+ # containing queryset and resolves against its parent instead.
40
+ self.name = name.name
41
+ self.depth = name.depth + 1
42
+ elif isinstance(name, str) and name and all(name.split("__")):
43
+ self.name = name
44
+ self.depth = 1
45
+ else:
46
+ raise ValueError(
47
+ "OuterRef requires a non-empty mapped field path or another OuterRef"
48
+ )
49
+
50
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
51
+ # Store the depth in SQLAlchemy's annotation metadata, never SQL text.
52
+ # Each containing Subquery/Exists consumes one correlation boundary.
53
+ return _marker(self.name, self.depth)
54
+
55
+
56
+ def _marker(name: str, depth: int):
57
+ return column("__async_orm_outer_ref__")._annotate({_OUTER_REF: (name, depth)})
58
+
59
+
60
+ class _QueryExpression(CombinableMixin, OrderableMixin):
61
+ __slots__ = ("queryset",)
62
+
63
+ def __init__(self, queryset):
64
+ from async_orm_kit.queryset.queryset import QuerySet
65
+
66
+ if not isinstance(queryset, QuerySet):
67
+ raise TypeError("Subquery/Exists requires a QuerySet")
68
+ self.queryset = queryset
69
+
70
+ def _compile(self, outer_resolver, context: ResolverContext, *, scalar: bool):
71
+ state = self.queryset._state.clone()
72
+ state.select_related.clear()
73
+ state.prefetch_related.clear()
74
+ state.for_update = None
75
+ state.order_by = state.order_by if scalar else []
76
+ if scalar:
77
+ if state.values_select is not None:
78
+ if len(state.values_select) != 1:
79
+ raise QueryStateError("Subquery requires a single selected column")
80
+ expression = state.values_select[0][1]
81
+ elif state.values_list_select is not None:
82
+ if len(state.values_list_select) != 1:
83
+ raise QueryStateError("Subquery requires a single selected column")
84
+ expression = state.values_list_select[0][1]
85
+ else:
86
+ if state.annotations or state.has_aggregates:
87
+ raise QueryStateError(
88
+ "Annotated Subquery requires explicit scalar projection"
89
+ )
90
+ primary_key = sa_inspect(self.queryset.model).primary_key
91
+ if len(primary_key) != 1:
92
+ raise QueryStateError(
93
+ "Composite-key Subquery requires explicit scalar projection"
94
+ )
95
+ expression = getattr(self.queryset.model, primary_key[0].key)
96
+ else:
97
+ expression = None
98
+ state.order_by.clear()
99
+
100
+ # Referring to the same mapped class on both sides of a correlation
101
+ # produces SQL such as "WHERE authors.id = authors.id". SQLAlchemy
102
+ # cannot distinguish the inner/outer identity unless the inner model
103
+ # is explicitly aliased. Reject the ambiguous form before rendering it.
104
+ if self.queryset.model is outer_resolver.model:
105
+ clauses = (
106
+ list(state.filters)
107
+ + list(state.having_conditions)
108
+ + list(state.annotations.values())
109
+ + list(state.order_by)
110
+ )
111
+ clauses.extend(expr for _, expr in (state.values_select or ()))
112
+ clauses.extend(expr for _, expr in (state.values_list_select or ()))
113
+ for clause in clauses:
114
+ for element in visitors.iterate(clause):
115
+ reference = getattr(element, "_annotations", {}).get(_OUTER_REF)
116
+ if reference is None:
117
+ continue
118
+ depth = reference[1] if isinstance(reference, tuple) else 1
119
+ if depth == 1:
120
+ raise QueryStateError(
121
+ "Self-correlated queries require a distinct inner "
122
+ "SQLAlchemy aliased(Model) entity"
123
+ )
124
+
125
+ def replace(element):
126
+ reference = getattr(element, "_annotations", {}).get(_OUTER_REF)
127
+ if reference is None:
128
+ return None
129
+ name, depth = reference if isinstance(reference, tuple) else (reference, 1)
130
+ if depth > 1:
131
+ return _marker(name, depth - 1)
132
+ return outer_resolver.resolve_to_column(name, context=context).column
133
+
134
+ def resolve_in(expression):
135
+ # Replacing references on the entire compiled SELECT forces
136
+ # SQLAlchemy to clone ORM JOIN aliases. A selected aggregate could
137
+ # then reference one alias while FROM uses another, causing a
138
+ # Cartesian product or an ambiguous column error. Resolve markers
139
+ # in the query state BEFORE the compiler constructs its FROM/JOIN
140
+ # graph, and leave unrelated column expressions intact.
141
+ if not any(
142
+ getattr(element, "_annotations", {}).get(_OUTER_REF) is not None
143
+ for element in visitors.iterate(expression)
144
+ ):
145
+ return expression
146
+ return visitors.replacement_traverse(expression, {}, replace)
147
+
148
+ state.filters = [resolve_in(expr) for expr in state.filters]
149
+ state.having_conditions = [resolve_in(expr) for expr in state.having_conditions]
150
+ state.annotations = {
151
+ key: resolve_in(expr) for key, expr in state.annotations.items()
152
+ }
153
+ state.values_select = (
154
+ [(key, resolve_in(expr)) for key, expr in state.values_select]
155
+ if state.values_select is not None
156
+ else None
157
+ )
158
+ state.values_list_select = (
159
+ [(key, resolve_in(expr)) for key, expr in state.values_list_select]
160
+ if state.values_list_select is not None
161
+ else None
162
+ )
163
+ state.order_by = [resolve_in(expr) for expr in state.order_by]
164
+ state.group_by_fields = [resolve_in(expr) for expr in state.group_by_fields]
165
+ state.distinct_on = [
166
+ resolve_in(expr) if not isinstance(expr, str) else expr
167
+ for expr in state.distinct_on
168
+ ]
169
+
170
+ statement = self.queryset._compiler.compile(state, allow_outer_refs=True)
171
+ if scalar:
172
+ if state.values_select is not None:
173
+ expression = state.values_select[0][1]
174
+ elif state.values_list_select is not None:
175
+ expression = state.values_list_select[0][1]
176
+ statement = statement.with_only_columns(
177
+ expression, maintain_column_froms=True
178
+ )
179
+ return statement
180
+
181
+
182
+ class Subquery(_QueryExpression):
183
+ """Use a single-column QuerySet as a scalar SQL subquery."""
184
+
185
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
186
+ return self._compile(field_resolver, context, scalar=True).scalar_subquery()
187
+
188
+
189
+ class Exists(_QueryExpression):
190
+ """Use a QuerySet as an automatically correlated EXISTS predicate."""
191
+
192
+ __slots__ = ("negated",)
193
+
194
+ def __init__(self, queryset, *, negated: bool = False):
195
+ super().__init__(queryset)
196
+ self.negated = negated
197
+
198
+ def __invert__(self):
199
+ return Exists(self.queryset, negated=not self.negated)
200
+
201
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
202
+ expression = self._compile(field_resolver, context, scalar=False).exists()
203
+ return ~expression if self.negated else expression
204
+
205
+
206
+ __all__ = ["Exists", "OuterRef", "Subquery"]
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy import literal
6
+
7
+ from async_orm_kit.query.lookups.resolver import FieldResolver, ResolverContext
8
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
9
+ from async_orm_kit.query.mixins.orderable import OrderableMixin
10
+ from async_orm_kit.types import SAFE_SCALAR_TYPES
11
+
12
+
13
+ class Value(CombinableMixin, OrderableMixin):
14
+ """Wraps a literal scalar value for use in database expressions.
15
+
16
+ Value objects convert Python scalars (str, int, float, bool, None, datetime, etc.)
17
+ to SQLAlchemy literal() expressions, enabling their use in annotations, Case/When
18
+ expressions, and F expressions. They ensure type-safe SQL generation by wrapping
19
+ literals that would otherwise be ambiguous or require special handling.
20
+
21
+ Common use cases include providing default values in Case expressions, creating
22
+ constant annotations, and mixing literals with F objects in arithmetic operations.
23
+ Value objects participate in the same expression system as F objects, supporting
24
+ arithmetic operations and comparisons through CombinableMixin.
25
+
26
+ Args:
27
+ value: Python scalar value to wrap. Supports str, int, float, bool, None,
28
+ datetime, date, time, Decimal, and other SQLAlchemy-compatible types.
29
+
30
+ Note:
31
+ Value objects are immutable and hashable, making them safe to reuse across
32
+ multiple queries. They resolve to SQLAlchemy literal() expressions at query
33
+ compilation time, ensuring proper SQL escaping and type handling.
34
+
35
+ Unlike F objects which reference database columns, Value objects represent
36
+ constant values that are embedded directly in the SQL query. This distinction
37
+ is important for understanding query behavior and optimization.
38
+ """
39
+
40
+ _value: Any
41
+ __slots__ = ("_value",)
42
+
43
+ @property
44
+ def value(self) -> Any:
45
+ return self._value
46
+
47
+ def __setattr__(self, name: str, value: Any) -> None:
48
+ raise AttributeError("Value expressions are immutable.")
49
+
50
+ def __init__(self, value: Any):
51
+ """Initialize Value with a Python scalar.
52
+
53
+ Args:
54
+ value: Scalar value to wrap. Common types: str, int, float, bool, None,
55
+ datetime, date, Decimal.
56
+ Example:
57
+ >>> Value("Active")
58
+ >>> Value(42)
59
+ >>> Value(3.14)
60
+ >>> Value(None)
61
+ """
62
+
63
+ if not isinstance(value, SAFE_SCALAR_TYPES):
64
+ raise TypeError(
65
+ f"Value() only accepts supported scalar values, got {type(value).__name__}."
66
+ )
67
+ object.__setattr__(self, "_value", value)
68
+
69
+ def resolve(self, field_resolver: FieldResolver, context: ResolverContext):
70
+ """Resolve to SQLAlchemy literal expression.
71
+
72
+ Converts the wrapped Python value to a SQLAlchemy literal() expression,
73
+ which handles proper SQL escaping and type conversion based on the database
74
+ dialect. The literal is embedded directly in the generated SQL.
75
+
76
+ Args:
77
+ field_resolver: FieldResolver instance (unused, for interface consistency).
78
+ context: ResolverContext (unused, for interface consistency).
79
+
80
+ Returns:
81
+ SQLAlchemy literal expression representing the wrapped value.
82
+
83
+ Example:
84
+ >>> # Internal usage during query compilation
85
+ >>> v = Value("Active")
86
+ >>> literal_expr = v.resolve(field_resolver, context)
87
+ >>> # Returns: literal("Active") -> SQL: 'Active'
88
+ """
89
+ return literal(self.value)
90
+
91
+ def __repr__(self):
92
+ """Return string representation showing wrapped value."""
93
+ return f"Value({self.value!r})"
94
+
95
+ def __eq__(self, other):
96
+ """Equality based on wrapped value for comparison and deduplication."""
97
+ return isinstance(other, Value) and self.value == other.value
98
+
99
+ def __hash__(self):
100
+ """Return hash based on wrapped value for use in sets and dicts."""
101
+ return hash(("Value", self.value))
@@ -0,0 +1,95 @@
1
+ """First-class SQL window expressions, without accidental GROUP BY injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sqlalchemy import func
8
+
9
+ from async_orm_kit.query.expressions.functions import _operand
10
+ from async_orm_kit.query.lookups.resolver import ResolverContext
11
+ from async_orm_kit.query.mixins.combinable import CombinableMixin
12
+ from async_orm_kit.query.mixins.orderable import OrderableMixin, OrderedExpression
13
+
14
+
15
+ class _WindowFunction(CombinableMixin, OrderableMixin):
16
+ __slots__ = ("name", "arguments")
17
+ _allowed = frozenset({"row_number", "rank", "dense_rank", "lag", "lead"})
18
+
19
+ def __init__(self, name: str, *arguments: Any):
20
+ if name not in self._allowed:
21
+ raise ValueError(f"Unsupported window function: {name!r}")
22
+ self.name = name
23
+ self.arguments = tuple(arguments)
24
+
25
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
26
+ return getattr(func, self.name)(
27
+ *[_operand(arg, field_resolver, context) for arg in self.arguments]
28
+ )
29
+
30
+
31
+ class RowNumber(_WindowFunction):
32
+ def __init__(self):
33
+ super().__init__("row_number")
34
+
35
+
36
+ class Rank(_WindowFunction):
37
+ def __init__(self):
38
+ super().__init__("rank")
39
+
40
+
41
+ class DenseRank(_WindowFunction):
42
+ def __init__(self):
43
+ super().__init__("dense_rank")
44
+
45
+
46
+ class Lag(_WindowFunction):
47
+ def __init__(self, expression: Any, offset: int = 1, default: Any = None):
48
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 1:
49
+ raise ValueError("Lag offset must be a positive integer")
50
+ super().__init__("lag", expression, offset, default)
51
+
52
+
53
+ class Lead(_WindowFunction):
54
+ def __init__(self, expression: Any, offset: int = 1, default: Any = None):
55
+ if isinstance(offset, bool) or not isinstance(offset, int) or offset < 1:
56
+ raise ValueError("Lead offset must be a positive integer")
57
+ super().__init__("lead", expression, offset, default)
58
+
59
+
60
+ class Window(CombinableMixin, OrderableMixin):
61
+ """Apply OVER/PARTITION BY/ORDER BY to a SQL expression.
62
+
63
+ ``order_by`` requires expressions with explicit ordering (`.asc()`/`.desc()`)
64
+ or raw SQLAlchemy order expressions. A string field name can be specified
65
+ using ``F('field').asc()`` rather than trusted raw SQL.
66
+ """
67
+
68
+ __slots__ = ("expression", "partition_by", "order_by")
69
+
70
+ def __init__(self, expression: Any, *, partition_by=(), order_by=()):
71
+ from sqlalchemy.sql.elements import ClauseElement
72
+
73
+ if not (isinstance(expression, (CombinableMixin, ClauseElement))):
74
+ raise TypeError("Window requires an ORM or SQLAlchemy SQL expression")
75
+ self.expression = expression
76
+ self.partition_by = tuple(partition_by)
77
+ self.order_by = tuple(order_by)
78
+ if isinstance(expression, (RowNumber, Rank, DenseRank)) and not self.order_by:
79
+ raise ValueError("Ranking window expressions require order_by")
80
+
81
+ def resolve(self, field_resolver, context: ResolverContext = ResolverContext()):
82
+ partition = [_operand(x, field_resolver, context) for x in self.partition_by]
83
+ ordering = [
84
+ x.resolve(field_resolver, context)
85
+ if isinstance(x, OrderedExpression)
86
+ else _operand(x, field_resolver, context)
87
+ for x in self.order_by
88
+ ]
89
+ return _operand(self.expression, field_resolver, context).over(
90
+ partition_by=partition or None,
91
+ order_by=ordering or None,
92
+ )
93
+
94
+
95
+ __all__ = ["DenseRank", "Lag", "Lead", "Rank", "RowNumber", "Window"]