mongo-aggro 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.
mongo_aggro/base.py ADDED
@@ -0,0 +1,195 @@
1
+ """Base classes for MongoDB aggregation pipeline stages."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Iterator
5
+ from typing import Any, Self
6
+
7
+ from pydantic import GetCoreSchemaHandler
8
+ from pydantic_core import CoreSchema, core_schema
9
+
10
+ # Sort direction constants for use with Sort stage and with_sort method
11
+ ASCENDING: int = 1
12
+ DESCENDING: int = -1
13
+
14
+ # Type alias for sort specification used in aggregation
15
+ # Uses int to be compatible with both Literal[-1, 1] and regular int values
16
+ SortSpec = dict[str, int]
17
+ # Type alias for aggregation input tuple (pipeline, sort)
18
+ AggregationInput = tuple[list[dict[str, Any]], SortSpec]
19
+
20
+
21
+ class BaseStage(ABC):
22
+ """Abstract base class for all MongoDB aggregation pipeline stages."""
23
+
24
+ @abstractmethod
25
+ def model_dump(self) -> dict[str, Any]:
26
+ """
27
+ Convert the stage to its MongoDB dictionary representation.
28
+
29
+ Returns:
30
+ dict[str, Any]: MongoDB aggregation stage dictionary
31
+ """
32
+ pass
33
+
34
+
35
+ class Pipeline:
36
+ """
37
+ MongoDB aggregation pipeline builder.
38
+
39
+ This class acts as a container for aggregation stages and can be
40
+ directly passed to MongoDB's aggregate() method. It implements
41
+ __iter__ to allow MongoDB drivers to iterate through the stages.
42
+
43
+ Example:
44
+ >>> pipeline = Pipeline()
45
+ >>> pipeline.add_stage(Match(field="status", value="active"))
46
+ >>> pipeline.add_stage(Group(id="$category", count={"$sum": 1}))
47
+ >>> collection.aggregate(pipeline)
48
+
49
+ >>> # Or with constructor
50
+ >>> pipeline = Pipeline([
51
+ ... Match(field="status", value="active"),
52
+ ... Unwind(path="items")
53
+ ... ])
54
+ """
55
+
56
+ def __init__(self, stages: list[BaseStage] | None = None) -> None:
57
+ """
58
+ Initialize the pipeline with optional initial stages.
59
+
60
+ Args:
61
+ stages: Optional list of initial pipeline stages
62
+ """
63
+ self._stages: list[BaseStage] = stages or []
64
+
65
+ def add_stage(self, stage: BaseStage) -> Self:
66
+ """
67
+ Append a new stage to the pipeline.
68
+
69
+ Args:
70
+ stage: A pipeline stage instance
71
+
72
+ Returns:
73
+ Self: Self for method chaining
74
+ """
75
+ self._stages.append(stage)
76
+ return self
77
+
78
+ def __iter__(self) -> Iterator[dict[str, Any]]:
79
+ """
80
+ Iterate through pipeline stages as dictionaries.
81
+
82
+ This allows the pipeline to be directly passed to MongoDB's
83
+ aggregate() method without calling any additional methods.
84
+
85
+ Yields:
86
+ dict[str, Any]: Each stage's MongoDB dictionary representation
87
+ """
88
+ for stage in self._stages:
89
+ yield stage.model_dump()
90
+
91
+ def __len__(self) -> int:
92
+ """Return the number of stages in the pipeline."""
93
+ return len(self._stages)
94
+
95
+ def __getitem__(self, index: int) -> BaseStage:
96
+ """Get a stage by index."""
97
+ return self._stages[index]
98
+
99
+ def to_list(self) -> list[dict[str, Any]]:
100
+ """
101
+ Convert the entire pipeline to a list of dictionaries.
102
+
103
+ Returns:
104
+ list[dict[str, Any]]: List of MongoDB stage dictionaries
105
+ """
106
+ return list(self)
107
+
108
+ def with_sort(self, sort: SortSpec) -> AggregationInput:
109
+ """
110
+ Return pipeline as aggregation input tuple with sort specification.
111
+
112
+ This is useful for integrating with pagination utilities that
113
+ expect a tuple of (pipeline, sort_spec). Common in Beanie ODM
114
+ pagination patterns.
115
+
116
+ Args:
117
+ sort: Sort specification dict with field names as keys
118
+ and -1 (descending) or 1 (ascending) as values
119
+
120
+ Returns:
121
+ AggregationInput: Tuple of (pipeline_list, sort_spec)
122
+
123
+ Example:
124
+ >>> pipeline = Pipeline([
125
+ ... Match(query={"status": "active"}),
126
+ ... Group(id="$category", total={"$sum": "$amount"})
127
+ ... ])
128
+ >>> # Use with pagination utilities
129
+ >>> aggregation_input = pipeline.with_sort({"total": -1})
130
+ >>> await query.paginate_and_cast(
131
+ ... query=base_query,
132
+ ... projection_model=OutputModel,
133
+ ... aggregation_input=aggregation_input
134
+ ... )
135
+ """
136
+ return (self.to_list(), sort)
137
+
138
+ def extend(self, stages: list[BaseStage]) -> Self:
139
+ """
140
+ Extend the pipeline with multiple stages.
141
+
142
+ Args:
143
+ stages: List of pipeline stages to add
144
+
145
+ Returns:
146
+ Self: Self for method chaining
147
+ """
148
+ self._stages.extend(stages)
149
+ return self
150
+
151
+ def extend_raw(self, raw_stages: list[dict[str, Any]]) -> Self:
152
+ """
153
+ Extend the pipeline with raw dictionary stages.
154
+
155
+ This is useful when combining typed stages with raw MongoDB
156
+ pipeline stages that may not have a corresponding class.
157
+
158
+ Args:
159
+ raw_stages: List of raw MongoDB stage dictionaries
160
+
161
+ Returns:
162
+ Self: Self for method chaining
163
+
164
+ Example:
165
+ >>> pipeline = Pipeline([Match(query={"status": "active"})])
166
+ >>> pipeline.extend_raw([
167
+ ... {"$addFields": {"computed": {"$multiply": ["$a", "$b"]}}},
168
+ ... {"$project": {"_id": 0, "result": "$computed"}}
169
+ ... ])
170
+ """
171
+ for raw_stage in raw_stages:
172
+ self._stages.append(_RawStage(raw_stage))
173
+ return self
174
+
175
+ @classmethod
176
+ def __get_pydantic_core_schema__(
177
+ cls, source_type: Any, handler: GetCoreSchemaHandler
178
+ ) -> CoreSchema:
179
+ """
180
+ Allow Pipeline to be used as a Pydantic field type.
181
+
182
+ This enables using Pipeline in other Pydantic models,
183
+ for example in Lookup's pipeline field.
184
+ """
185
+ return core_schema.is_instance_schema(cls)
186
+
187
+
188
+ class _RawStage(BaseStage):
189
+ """Internal class for wrapping raw dictionary stages."""
190
+
191
+ def __init__(self, raw: dict[str, Any]) -> None:
192
+ self._raw = raw
193
+
194
+ def model_dump(self) -> dict[str, Any]:
195
+ return self._raw
@@ -0,0 +1,247 @@
1
+ """Query and expression operators for MongoDB aggregation."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class QueryOperator(BaseModel):
9
+ """Base class for query operators used in $match and other stages."""
10
+
11
+ model_config = ConfigDict(
12
+ populate_by_name=True,
13
+ extra="forbid",
14
+ )
15
+
16
+
17
+ class And(QueryOperator):
18
+ """
19
+ Logical AND operator for combining multiple conditions.
20
+
21
+ Example:
22
+ >>> And(conditions=[
23
+ ... {"status": "active"},
24
+ ... {"age": {"$gt": 18}}
25
+ ... ]).model_dump()
26
+ {"$and": [{"status": "active"}, {"age": {"$gt": 18}}]}
27
+ """
28
+
29
+ conditions: list[dict[str, Any]] = Field(
30
+ ..., description="List of conditions to AND together"
31
+ )
32
+
33
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
34
+ return {"$and": self.conditions}
35
+
36
+
37
+ class Or(QueryOperator):
38
+ """
39
+ Logical OR operator for combining multiple conditions.
40
+
41
+ Example:
42
+ >>> Or(conditions=[
43
+ ... {"status": "active"},
44
+ ... {"status": "pending"}
45
+ ... ]).model_dump()
46
+ {"$or": [{"status": "active"}, {"status": "pending"}]}
47
+ """
48
+
49
+ conditions: list[dict[str, Any]] = Field(
50
+ ..., description="List of conditions to OR together"
51
+ )
52
+
53
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
54
+ return {"$or": self.conditions}
55
+
56
+
57
+ class Not(QueryOperator):
58
+ """
59
+ Logical NOT operator for negating a condition.
60
+
61
+ Example:
62
+ >>> Not(condition={"$regex": "^test"}).model_dump()
63
+ {"$not": {"$regex": "^test"}}
64
+ """
65
+
66
+ condition: dict[str, Any] = Field(..., description="Condition to negate")
67
+
68
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
69
+ return {"$not": self.condition}
70
+
71
+
72
+ class Nor(QueryOperator):
73
+ """
74
+ Logical NOR operator - matches documents that fail all conditions.
75
+
76
+ Example:
77
+ >>> Nor(conditions=[
78
+ ... {"price": {"$gt": 1000}},
79
+ ... {"rating": {"$lt": 3}}
80
+ ... ]).model_dump()
81
+ {"$nor": [{"price": {"$gt": 1000}}, {"rating": {"$lt": 3}}]}
82
+ """
83
+
84
+ conditions: list[dict[str, Any]] = Field(
85
+ ..., description="List of conditions to NOR together"
86
+ )
87
+
88
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
89
+ return {"$nor": self.conditions}
90
+
91
+
92
+ class Expr(QueryOperator):
93
+ """
94
+ $expr operator for using aggregation expressions in queries.
95
+
96
+ Example:
97
+ >>> Expr(expression={"$eq": ["$field1", "$field2"]}).model_dump()
98
+ {"$expr": {"$eq": ["$field1", "$field2"]}}
99
+ """
100
+
101
+ expression: dict[str, Any] = Field(
102
+ ..., description="Aggregation expression"
103
+ )
104
+
105
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
106
+ return {"$expr": self.expression}
107
+
108
+
109
+ # Comparison operators
110
+ class Eq(QueryOperator):
111
+ """$eq comparison operator."""
112
+
113
+ value: Any = Field(..., description="Value to compare")
114
+
115
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
116
+ return {"$eq": self.value}
117
+
118
+
119
+ class Ne(QueryOperator):
120
+ """$ne (not equal) comparison operator."""
121
+
122
+ value: Any = Field(..., description="Value to compare")
123
+
124
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
125
+ return {"$ne": self.value}
126
+
127
+
128
+ class Gt(QueryOperator):
129
+ """$gt (greater than) comparison operator."""
130
+
131
+ value: Any = Field(..., description="Value to compare")
132
+
133
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
134
+ return {"$gt": self.value}
135
+
136
+
137
+ class Gte(QueryOperator):
138
+ """$gte (greater than or equal) comparison operator."""
139
+
140
+ value: Any = Field(..., description="Value to compare")
141
+
142
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
143
+ return {"$gte": self.value}
144
+
145
+
146
+ class Lt(QueryOperator):
147
+ """$lt (less than) comparison operator."""
148
+
149
+ value: Any = Field(..., description="Value to compare")
150
+
151
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
152
+ return {"$lt": self.value}
153
+
154
+
155
+ class Lte(QueryOperator):
156
+ """$lte (less than or equal) comparison operator."""
157
+
158
+ value: Any = Field(..., description="Value to compare")
159
+
160
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
161
+ return {"$lte": self.value}
162
+
163
+
164
+ class In(QueryOperator):
165
+ """$in operator - matches any value in the array."""
166
+
167
+ values: list[Any] = Field(..., description="List of values to match")
168
+
169
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
170
+ return {"$in": self.values}
171
+
172
+
173
+ class Nin(QueryOperator):
174
+ """$nin operator - matches none of the values in the array."""
175
+
176
+ values: list[Any] = Field(..., description="List of values to exclude")
177
+
178
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
179
+ return {"$nin": self.values}
180
+
181
+
182
+ class Regex(QueryOperator):
183
+ """$regex operator for pattern matching."""
184
+
185
+ pattern: str = Field(..., description="Regular expression pattern")
186
+ options: str | None = Field(
187
+ default=None, description="Regex options (i, m, x, s)"
188
+ )
189
+
190
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
191
+ result: dict[str, Any] = {"$regex": self.pattern}
192
+ if self.options:
193
+ result["$options"] = self.options
194
+ return result
195
+
196
+
197
+ class Exists(QueryOperator):
198
+ """$exists operator - matches documents where field exists/doesn't."""
199
+
200
+ exists: bool = Field(
201
+ default=True, description="True if field should exist"
202
+ )
203
+
204
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
205
+ return {"$exists": self.exists}
206
+
207
+
208
+ class Type(QueryOperator):
209
+ """$type operator - matches documents where field is of specified type."""
210
+
211
+ bson_type: str | int | list[str | int] = Field(
212
+ ..., description="BSON type(s) to match"
213
+ )
214
+
215
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
216
+ return {"$type": self.bson_type}
217
+
218
+
219
+ class ElemMatch(QueryOperator):
220
+ """$elemMatch operator - matches array elements."""
221
+
222
+ conditions: dict[str, Any] = Field(
223
+ ..., description="Conditions for array elements"
224
+ )
225
+
226
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
227
+ return {"$elemMatch": self.conditions}
228
+
229
+
230
+ class Size(QueryOperator):
231
+ """$size operator - matches arrays with specific length."""
232
+
233
+ size: int = Field(..., description="Array size to match")
234
+
235
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
236
+ return {"$size": self.size}
237
+
238
+
239
+ class All(QueryOperator):
240
+ """$all operator - matches arrays containing all specified elements."""
241
+
242
+ values: list[Any] = Field(
243
+ ..., description="Values that must all be present"
244
+ )
245
+
246
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
247
+ return {"$all": self.values}