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.
@@ -0,0 +1,188 @@
1
+ """
2
+ Mongo Aggro - MongoDB Aggregation Pipeline Builder with Pydantic.
3
+
4
+ A Python package for building MongoDB aggregation pipelines with
5
+ strong type checking using Pydantic models.
6
+
7
+ Example:
8
+ >>> from mongo_aggro import Pipeline, Match, Unwind, Group, Sort, Limit
9
+ >>>
10
+ >>> pipeline = Pipeline()
11
+ >>> pipeline.add_stage(Match(query={"status": "active"}))
12
+ >>> pipeline.add_stage(Unwind(path="items"))
13
+ >>> pipeline.add_stage(Group(id="$category", count={"$sum": 1}))
14
+ >>> pipeline.add_stage(Sort(fields={"count": -1}))
15
+ >>> pipeline.add_stage(Limit(count=10))
16
+ >>>
17
+ >>> # Pass directly to MongoDB - no need to call any methods
18
+ >>> results = collection.aggregate(pipeline)
19
+ """
20
+
21
+ from .accumulators import (
22
+ Accumulate,
23
+ Accumulator,
24
+ AddToSet,
25
+ Avg,
26
+ BottomN,
27
+ Count_,
28
+ First,
29
+ FirstN,
30
+ Last,
31
+ LastN,
32
+ Max,
33
+ MaxN,
34
+ MergeObjects,
35
+ Min,
36
+ MinN,
37
+ Push,
38
+ StdDevPop,
39
+ StdDevSamp,
40
+ Sum,
41
+ TopN,
42
+ merge_accumulators,
43
+ )
44
+ from .base import (
45
+ ASCENDING,
46
+ DESCENDING,
47
+ AggregationInput,
48
+ BaseStage,
49
+ Pipeline,
50
+ SortSpec,
51
+ )
52
+ from .operators import (
53
+ All,
54
+ And,
55
+ ElemMatch,
56
+ Eq,
57
+ Exists,
58
+ Expr,
59
+ Gt,
60
+ Gte,
61
+ In,
62
+ Lt,
63
+ Lte,
64
+ Ne,
65
+ Nin,
66
+ Nor,
67
+ Not,
68
+ Or,
69
+ QueryOperator,
70
+ Regex,
71
+ Size,
72
+ Type,
73
+ )
74
+ from .stages import (
75
+ AddFields,
76
+ Bucket,
77
+ BucketAuto,
78
+ Count,
79
+ Densify,
80
+ Documents,
81
+ Facet,
82
+ Fill,
83
+ GeoNear,
84
+ GraphLookup,
85
+ Group,
86
+ Limit,
87
+ Lookup,
88
+ Match,
89
+ Merge,
90
+ Out,
91
+ Project,
92
+ Redact,
93
+ ReplaceRoot,
94
+ ReplaceWith,
95
+ Sample,
96
+ Set,
97
+ SetWindowFields,
98
+ Skip,
99
+ Sort,
100
+ SortByCount,
101
+ UnionWith,
102
+ Unset,
103
+ Unwind,
104
+ )
105
+
106
+ __all__ = [
107
+ # Base classes and types
108
+ "Pipeline",
109
+ "BaseStage",
110
+ "SortSpec",
111
+ "AggregationInput",
112
+ # Sort direction constants
113
+ "ASCENDING",
114
+ "DESCENDING",
115
+ # Query operators
116
+ "QueryOperator",
117
+ "And",
118
+ "Or",
119
+ "Not",
120
+ "Nor",
121
+ "Expr",
122
+ "Eq",
123
+ "Ne",
124
+ "Gt",
125
+ "Gte",
126
+ "Lt",
127
+ "Lte",
128
+ "In",
129
+ "Nin",
130
+ "Regex",
131
+ "Exists",
132
+ "Type",
133
+ "ElemMatch",
134
+ "Size",
135
+ "All",
136
+ # Accumulators
137
+ "Accumulator",
138
+ "Sum",
139
+ "Avg",
140
+ "Min",
141
+ "Max",
142
+ "First",
143
+ "Last",
144
+ "Push",
145
+ "AddToSet",
146
+ "StdDevPop",
147
+ "StdDevSamp",
148
+ "Count_",
149
+ "MergeObjects",
150
+ "Accumulate",
151
+ "TopN",
152
+ "BottomN",
153
+ "FirstN",
154
+ "LastN",
155
+ "MaxN",
156
+ "MinN",
157
+ "merge_accumulators",
158
+ # Stages
159
+ "Match",
160
+ "Project",
161
+ "Group",
162
+ "Sort",
163
+ "Limit",
164
+ "Skip",
165
+ "Unwind",
166
+ "Lookup",
167
+ "AddFields",
168
+ "Set",
169
+ "Unset",
170
+ "Count",
171
+ "SortByCount",
172
+ "Facet",
173
+ "Bucket",
174
+ "BucketAuto",
175
+ "ReplaceRoot",
176
+ "ReplaceWith",
177
+ "Sample",
178
+ "Out",
179
+ "Merge",
180
+ "Redact",
181
+ "UnionWith",
182
+ "GeoNear",
183
+ "GraphLookup",
184
+ "SetWindowFields",
185
+ "Densify",
186
+ "Fill",
187
+ "Documents",
188
+ ]
@@ -0,0 +1,474 @@
1
+ """Accumulator operators for MongoDB $group stage."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+
8
+ class Accumulator(BaseModel):
9
+ """Base class for accumulator operators used in $group stage."""
10
+
11
+ model_config = ConfigDict(
12
+ populate_by_name=True,
13
+ extra="forbid",
14
+ )
15
+
16
+ name: str = Field(..., description="Output field name")
17
+
18
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
19
+ raise NotImplementedError
20
+
21
+
22
+ class Sum(Accumulator):
23
+ """
24
+ $sum accumulator - sums numeric values.
25
+
26
+ Example:
27
+ >>> Sum(name="totalQuantity", field="quantity").model_dump()
28
+ {"totalQuantity": {"$sum": "$quantity"}}
29
+
30
+ >>> Sum(name="count", value=1).model_dump()
31
+ {"count": {"$sum": 1}}
32
+ """
33
+
34
+ field: str | None = Field(
35
+ default=None, description="Field path to sum (without $)"
36
+ )
37
+ value: int | float | None = Field(
38
+ default=None, description="Literal value to sum (e.g., 1 for counting)"
39
+ )
40
+
41
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
42
+ if self.field is not None:
43
+ field_path = (
44
+ f"${self.field}"
45
+ if not self.field.startswith("$")
46
+ else self.field
47
+ )
48
+ return {self.name: {"$sum": field_path}}
49
+ return {self.name: {"$sum": self.value}}
50
+
51
+
52
+ class Avg(Accumulator):
53
+ """
54
+ $avg accumulator - calculates average of numeric values.
55
+
56
+ Example:
57
+ >>> Avg(name="avgPrice", field="price").model_dump()
58
+ {"avgPrice": {"$avg": "$price"}}
59
+ """
60
+
61
+ field: str = Field(..., description="Field path to average (without $)")
62
+
63
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
64
+ field_path = (
65
+ f"${self.field}" if not self.field.startswith("$") else self.field
66
+ )
67
+ return {self.name: {"$avg": field_path}}
68
+
69
+
70
+ class Min(Accumulator):
71
+ """
72
+ $min accumulator - returns minimum value.
73
+
74
+ Example:
75
+ >>> Min(name="minPrice", field="price").model_dump()
76
+ {"minPrice": {"$min": "$price"}}
77
+ """
78
+
79
+ field: str = Field(..., description="Field path (without $)")
80
+
81
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
82
+ field_path = (
83
+ f"${self.field}" if not self.field.startswith("$") else self.field
84
+ )
85
+ return {self.name: {"$min": field_path}}
86
+
87
+
88
+ class Max(Accumulator):
89
+ """
90
+ $max accumulator - returns maximum value.
91
+
92
+ Example:
93
+ >>> Max(name="maxPrice", field="price").model_dump()
94
+ {"maxPrice": {"$max": "$price"}}
95
+ """
96
+
97
+ field: str = Field(..., description="Field path (without $)")
98
+
99
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
100
+ field_path = (
101
+ f"${self.field}" if not self.field.startswith("$") else self.field
102
+ )
103
+ return {self.name: {"$max": field_path}}
104
+
105
+
106
+ class First(Accumulator):
107
+ """
108
+ $first accumulator - returns first value in group.
109
+
110
+ Example:
111
+ >>> First(name="firstItem", field="item").model_dump()
112
+ {"firstItem": {"$first": "$item"}}
113
+ """
114
+
115
+ field: str = Field(..., description="Field path (without $)")
116
+
117
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
118
+ field_path = (
119
+ f"${self.field}" if not self.field.startswith("$") else self.field
120
+ )
121
+ return {self.name: {"$first": field_path}}
122
+
123
+
124
+ class Last(Accumulator):
125
+ """
126
+ $last accumulator - returns last value in group.
127
+
128
+ Example:
129
+ >>> Last(name="lastItem", field="item").model_dump()
130
+ {"lastItem": {"$last": "$item"}}
131
+ """
132
+
133
+ field: str = Field(..., description="Field path (without $)")
134
+
135
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
136
+ field_path = (
137
+ f"${self.field}" if not self.field.startswith("$") else self.field
138
+ )
139
+ return {self.name: {"$last": field_path}}
140
+
141
+
142
+ class Push(Accumulator):
143
+ """
144
+ $push accumulator - creates array of values.
145
+
146
+ Example:
147
+ >>> Push(name="items", field="item").model_dump()
148
+ {"items": {"$push": "$item"}}
149
+
150
+ >>> Push(
151
+ name="details",
152
+ expression={"name": "$name", "qty": "$qty"}
153
+ ).model_dump()
154
+ {"details": {"$push": {"name": "$name", "qty": "$qty"}}}
155
+ """
156
+
157
+ field: str | None = Field(
158
+ default=None, description="Field path to push (without $)"
159
+ )
160
+ expression: dict[str, Any] | None = Field(
161
+ default=None, description="Expression to push"
162
+ )
163
+
164
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
165
+ if self.expression is not None:
166
+ return {self.name: {"$push": self.expression}}
167
+ field_path = (
168
+ f"${self.field}"
169
+ if self.field and not self.field.startswith("$")
170
+ else self.field
171
+ )
172
+ return {self.name: {"$push": field_path}}
173
+
174
+
175
+ class AddToSet(Accumulator):
176
+ """
177
+ $addToSet accumulator - creates array of unique values.
178
+
179
+ Example:
180
+ >>> AddToSet(name="uniqueTags", field="tag").model_dump()
181
+ {"uniqueTags": {"$addToSet": "$tag"}}
182
+ """
183
+
184
+ field: str = Field(..., description="Field path (without $)")
185
+
186
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
187
+ field_path = (
188
+ f"${self.field}" if not self.field.startswith("$") else self.field
189
+ )
190
+ return {self.name: {"$addToSet": field_path}}
191
+
192
+
193
+ class StdDevPop(Accumulator):
194
+ """
195
+ $stdDevPop accumulator - population standard deviation.
196
+
197
+ Example:
198
+ >>> StdDevPop(name="stdDev", field="score").model_dump()
199
+ {"stdDev": {"$stdDevPop": "$score"}}
200
+ """
201
+
202
+ field: str = Field(..., description="Field path (without $)")
203
+
204
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
205
+ field_path = (
206
+ f"${self.field}" if not self.field.startswith("$") else self.field
207
+ )
208
+ return {self.name: {"$stdDevPop": field_path}}
209
+
210
+
211
+ class StdDevSamp(Accumulator):
212
+ """
213
+ $stdDevSamp accumulator - sample standard deviation.
214
+
215
+ Example:
216
+ >>> StdDevSamp(name="stdDevSample", field="score").model_dump()
217
+ {"stdDevSample": {"$stdDevSamp": "$score"}}
218
+ """
219
+
220
+ field: str = Field(..., description="Field path (without $)")
221
+
222
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
223
+ field_path = (
224
+ f"${self.field}" if not self.field.startswith("$") else self.field
225
+ )
226
+ return {self.name: {"$stdDevSamp": field_path}}
227
+
228
+
229
+ class Count_(Accumulator):
230
+ """
231
+ $count accumulator - counts documents in group (MongoDB 5.0+).
232
+
233
+ Example:
234
+ >>> Count_(name="totalDocs").model_dump()
235
+ {"totalDocs": {"$count": {}}}
236
+ """
237
+
238
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
239
+ return {self.name: {"$count": {}}}
240
+
241
+
242
+ class MergeObjects(Accumulator):
243
+ """
244
+ $mergeObjects accumulator - merges documents into single document.
245
+
246
+ Example:
247
+ >>> MergeObjects(name="merged", field="details").model_dump()
248
+ {"merged": {"$mergeObjects": "$details"}}
249
+ """
250
+
251
+ field: str = Field(..., description="Field path (without $)")
252
+
253
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
254
+ field_path = (
255
+ f"${self.field}" if not self.field.startswith("$") else self.field
256
+ )
257
+ return {self.name: {"$mergeObjects": field_path}}
258
+
259
+
260
+ class Accumulate(Accumulator):
261
+ """
262
+ $accumulator - custom JavaScript accumulator (MongoDB 4.4+).
263
+
264
+ Example:
265
+ >>> Accumulate( # noqa
266
+ ... name="custom",
267
+ ... init="function() { return { count: 0 } }",
268
+ ... accumulate="function(state, val) { state.count++; return state }",
269
+ ... merge="function(s1, s2) { return { count: s1.count + s2.count } }",
270
+ ... finalize="function(state) { return state.count }",
271
+ ... lang="js"
272
+ ... ).model_dump()
273
+ """
274
+
275
+ init: str = Field(..., description="Init function")
276
+ accumulate: str = Field(..., description="Accumulate function")
277
+ merge: str = Field(..., description="Merge function")
278
+ finalize: str | None = Field(default=None, description="Finalize function")
279
+ init_args: list[Any] | None = Field(
280
+ default=None,
281
+ validation_alias="initArgs",
282
+ serialization_alias="initArgs",
283
+ description="Init function arguments",
284
+ )
285
+ accumulate_args: list[Any] | None = Field(
286
+ default=None,
287
+ validation_alias="accumulateArgs",
288
+ serialization_alias="accumulateArgs",
289
+ description="Accumulate function arguments",
290
+ )
291
+ lang: str = Field(default="js", description="Language (js)")
292
+
293
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
294
+ result: dict[str, Any] = {
295
+ "init": self.init,
296
+ "accumulate": self.accumulate,
297
+ "merge": self.merge,
298
+ "lang": self.lang,
299
+ }
300
+ if self.finalize is not None:
301
+ result["finalize"] = self.finalize
302
+ if self.init_args is not None:
303
+ result["initArgs"] = self.init_args
304
+ if self.accumulate_args is not None:
305
+ result["accumulateArgs"] = self.accumulate_args
306
+ return {self.name: {"$accumulator": result}}
307
+
308
+
309
+ class TopN(Accumulator):
310
+ """
311
+ $topN accumulator - returns top N elements (MongoDB 5.2+).
312
+
313
+ Example:
314
+ >>> TopN(
315
+ ... name="top3",
316
+ ... n=3,
317
+ ... sort_by={"score": -1},
318
+ ... output="$item"
319
+ ... ).model_dump()
320
+ {
321
+ "top3": {
322
+ "$topN": {"n": 3, "sortBy": {"score": -1},
323
+ "output": "$item"}
324
+ }
325
+ }
326
+ """
327
+
328
+ n: int = Field(..., gt=0, description="Number of results")
329
+ sort_by: dict[str, int] = Field(
330
+ ...,
331
+ validation_alias="sortBy",
332
+ serialization_alias="sortBy",
333
+ description="Sort specification",
334
+ )
335
+ output: str | dict[str, Any] = Field(..., description="Output expression")
336
+
337
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
338
+ return {
339
+ self.name: {
340
+ "$topN": {
341
+ "n": self.n,
342
+ "sortBy": self.sort_by,
343
+ "output": self.output,
344
+ }
345
+ }
346
+ }
347
+
348
+
349
+ class BottomN(Accumulator):
350
+ """
351
+ $bottomN accumulator - returns bottom N elements (MongoDB 5.2+).
352
+
353
+ Example:
354
+ >>> BottomN(
355
+ ... name="bottom3",
356
+ ... n=3,
357
+ ... sort_by={"score": -1},
358
+ ... output="$item"
359
+ ... ).model_dump()
360
+ {
361
+ "bottom3": {
362
+ "$bottomN": {"n": 3, "sortBy": {"score": -1},
363
+ "output": "$item"}
364
+ }
365
+ }
366
+ """
367
+
368
+ n: int = Field(..., gt=0, description="Number of results")
369
+ sort_by: dict[str, int] = Field(
370
+ ...,
371
+ validation_alias="sortBy",
372
+ serialization_alias="sortBy",
373
+ description="Sort specification",
374
+ )
375
+ output: str | dict[str, Any] = Field(..., description="Output expression")
376
+
377
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
378
+ return {
379
+ self.name: {
380
+ "$bottomN": {
381
+ "n": self.n,
382
+ "sortBy": self.sort_by,
383
+ "output": self.output,
384
+ }
385
+ }
386
+ }
387
+
388
+
389
+ class FirstN(Accumulator):
390
+ """
391
+ $firstN accumulator - returns first N elements (MongoDB 5.2+).
392
+
393
+ Example:
394
+ >>> FirstN(name="first3", n=3, input="$item").model_dump()
395
+ {"first3": {"$firstN": {"n": 3, "input": "$item"}}}
396
+ """
397
+
398
+ n: int = Field(..., gt=0, description="Number of results")
399
+ input: str | dict[str, Any] = Field(..., description="Input expression")
400
+
401
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
402
+ return {self.name: {"$firstN": {"n": self.n, "input": self.input}}}
403
+
404
+
405
+ class LastN(Accumulator):
406
+ """
407
+ $lastN accumulator - returns last N elements (MongoDB 5.2+).
408
+
409
+ Example:
410
+ >>> LastN(name="last3", n=3, input="$item").model_dump()
411
+ {"last3": {"$lastN": {"n": 3, "input": "$item"}}}
412
+ """
413
+
414
+ n: int = Field(..., gt=0, description="Number of results")
415
+ input: str | dict[str, Any] = Field(..., description="Input expression")
416
+
417
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
418
+ return {self.name: {"$lastN": {"n": self.n, "input": self.input}}}
419
+
420
+
421
+ class MaxN(Accumulator):
422
+ """
423
+ $maxN accumulator - returns N maximum values (MongoDB 5.2+).
424
+
425
+ Example:
426
+ >>> MaxN(name="top3Scores", n=3, input="$score").model_dump()
427
+ {"top3Scores": {"$maxN": {"n": 3, "input": "$score"}}}
428
+ """
429
+
430
+ n: int = Field(..., gt=0, description="Number of results")
431
+ input: str | dict[str, Any] = Field(..., description="Input expression")
432
+
433
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
434
+ return {self.name: {"$maxN": {"n": self.n, "input": self.input}}}
435
+
436
+
437
+ class MinN(Accumulator):
438
+ """
439
+ $minN accumulator - returns N minimum values (MongoDB 5.2+).
440
+
441
+ Example:
442
+ >>> MinN(name="lowest3", n=3, input="$score").model_dump()
443
+ {"lowest3": {"$minN": {"n": 3, "input": "$score"}}}
444
+ """
445
+
446
+ n: int = Field(..., gt=0, description="Number of results")
447
+ input: str | dict[str, Any] = Field(..., description="Input expression")
448
+
449
+ def model_dump(self, **kwargs: Any) -> dict[str, dict[str, Any]]:
450
+ return {self.name: {"$minN": {"n": self.n, "input": self.input}}}
451
+
452
+
453
+ def merge_accumulators(*accumulators: Accumulator) -> dict[str, Any]:
454
+ """
455
+ Merge multiple accumulators into a single dictionary for Group stage.
456
+
457
+ Example:
458
+ >>> merge_accumulators(
459
+ ... Sum(name="total", field="amount"),
460
+ ... Avg(name="average", field="amount"),
461
+ ... Count_(name="count")
462
+ ... )
463
+ {
464
+ "total": {
465
+ "$sum": "$amount"},
466
+ "average": {"$avg": "$amount"},
467
+ "count": {"$count": {}
468
+ }
469
+ }
470
+ """
471
+ result: dict[str, Any] = {}
472
+ for acc in accumulators:
473
+ result.update(acc.model_dump())
474
+ return result