mongo-pipebuilder 0.3.0__tar.gz → 0.3.1__tar.gz
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_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/LICENSE +0 -0
- {mongo_pipebuilder-0.3.0/src/mongo_pipebuilder.egg-info → mongo_pipebuilder-0.3.1}/PKG-INFO +51 -3
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/README.md +50 -2
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/pyproject.toml +1 -1
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/setup.cfg +0 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder/__init__.py +1 -1
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder/builder.py +103 -1
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1/src/mongo_pipebuilder.egg-info}/PKG-INFO +51 -3
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/SOURCES.txt +0 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/dependency_links.txt +0 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/requires.txt +0 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/top_level.txt +0 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder.py +0 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_debug.py +51 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_insert.py +3 -3
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_validation.py +0 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_validation_existing.py +6 -0
- {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_validation_new.py +0 -0
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mongo-pipebuilder
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Type-safe, fluent MongoDB aggregation pipeline builder
|
|
5
5
|
Author-email: seligoroff <seligoroff@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -252,7 +252,7 @@ builder.prepend({"$match": {"deleted": False}})
|
|
|
252
252
|
Inserts a stage at a specific position (0-based index) in the pipeline.
|
|
253
253
|
|
|
254
254
|
```python
|
|
255
|
-
builder.match({"status": "active"}).group(
|
|
255
|
+
builder.match({"status": "active"}).group("$category", {"count": {"$sum": 1}})
|
|
256
256
|
builder.insert_at(1, {"$sort": {"name": 1}})
|
|
257
257
|
# Pipeline: [{"$match": {...}}, {"$sort": {...}}, {"$group": {...}}]
|
|
258
258
|
```
|
|
@@ -327,6 +327,15 @@ print(builder.pretty_print())
|
|
|
327
327
|
# ]
|
|
328
328
|
```
|
|
329
329
|
|
|
330
|
+
##### `pretty_print_stage(stage: Union[int, Dict[str, Any]], indent: int = 2, ensure_ascii: bool = False) -> str`
|
|
331
|
+
|
|
332
|
+
Returns a formatted JSON string representation of a single stage (by index or by dict).
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
builder = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
336
|
+
print(builder.pretty_print_stage(0)) # Prints the $match stage
|
|
337
|
+
```
|
|
338
|
+
|
|
330
339
|
##### `to_json_file(filepath: Union[str, Path], indent: int = 2, ensure_ascii: bool = False, metadata: Optional[Dict[str, Any]] = None) -> None`
|
|
331
340
|
|
|
332
341
|
Saves the pipeline to a JSON file. Useful for debugging, comparison, or versioning.
|
|
@@ -345,6 +354,17 @@ builder.to_json_file(
|
|
|
345
354
|
)
|
|
346
355
|
```
|
|
347
356
|
|
|
357
|
+
##### `compare_with(other: PipelineBuilder, context_lines: int = 3) -> str`
|
|
358
|
+
|
|
359
|
+
Returns a unified diff between two pipelines (useful for comparing “new” builder pipelines vs legacy/template pipelines).
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
legacy = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
363
|
+
new = PipelineBuilder().match({"status": "inactive"}).limit(10)
|
|
364
|
+
|
|
365
|
+
print(new.compare_with(legacy))
|
|
366
|
+
```
|
|
367
|
+
|
|
348
368
|
##### `build() -> List[Dict[str, Any]]`
|
|
349
369
|
|
|
350
370
|
Returns the complete pipeline as a list of stage dictionaries.
|
|
@@ -500,11 +520,39 @@ base = get_base_pipeline(user_id)
|
|
|
500
520
|
# Create multiple queries from cached base
|
|
501
521
|
recent = base.copy().sort({"createdAt": -1}).limit(10).build()
|
|
502
522
|
by_category = base.copy().match({"category": "tech"}).build()
|
|
503
|
-
with_stats = base.copy().group(
|
|
523
|
+
with_stats = base.copy().group("$category", {"count": {"$sum": 1}}).build()
|
|
504
524
|
|
|
505
525
|
# Base pipeline is safely cached and reused
|
|
506
526
|
```
|
|
507
527
|
|
|
528
|
+
## Best Practices
|
|
529
|
+
|
|
530
|
+
### Array `_id` after `$group`: prefer `$arrayElemAt` and materialize fields
|
|
531
|
+
|
|
532
|
+
If you use `$group` with an array `_id` (e.g. `["_idSeason", "_idTournament"]`), avoid relying on `$_id` later in the pipeline.
|
|
533
|
+
Instead, **extract elements with `$arrayElemAt` and store them into explicit fields**, then use those fields in subsequent stages.
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
pipeline = (
|
|
537
|
+
PipelineBuilder()
|
|
538
|
+
.group(
|
|
539
|
+
group_by=["$idSeason", "$idTournament"],
|
|
540
|
+
accumulators={"idTeams": {"$addToSet": "$idTeam"}},
|
|
541
|
+
)
|
|
542
|
+
.project({
|
|
543
|
+
"idSeason": {"$arrayElemAt": ["$_id", 0]},
|
|
544
|
+
"idTournament": {"$arrayElemAt": ["$_id", 1]},
|
|
545
|
+
"idTeams": 1,
|
|
546
|
+
# Optional: preserve array _id explicitly if you really need it later
|
|
547
|
+
# "_id": "$_id",
|
|
548
|
+
})
|
|
549
|
+
.build()
|
|
550
|
+
)
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
This pattern reduces surprises and helps avoid errors like:
|
|
554
|
+
`$first's argument must be an array, but is object`.
|
|
555
|
+
|
|
508
556
|
#### Example: Pipeline Factories
|
|
509
557
|
|
|
510
558
|
```python
|
|
@@ -224,7 +224,7 @@ builder.prepend({"$match": {"deleted": False}})
|
|
|
224
224
|
Inserts a stage at a specific position (0-based index) in the pipeline.
|
|
225
225
|
|
|
226
226
|
```python
|
|
227
|
-
builder.match({"status": "active"}).group(
|
|
227
|
+
builder.match({"status": "active"}).group("$category", {"count": {"$sum": 1}})
|
|
228
228
|
builder.insert_at(1, {"$sort": {"name": 1}})
|
|
229
229
|
# Pipeline: [{"$match": {...}}, {"$sort": {...}}, {"$group": {...}}]
|
|
230
230
|
```
|
|
@@ -299,6 +299,15 @@ print(builder.pretty_print())
|
|
|
299
299
|
# ]
|
|
300
300
|
```
|
|
301
301
|
|
|
302
|
+
##### `pretty_print_stage(stage: Union[int, Dict[str, Any]], indent: int = 2, ensure_ascii: bool = False) -> str`
|
|
303
|
+
|
|
304
|
+
Returns a formatted JSON string representation of a single stage (by index or by dict).
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
builder = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
308
|
+
print(builder.pretty_print_stage(0)) # Prints the $match stage
|
|
309
|
+
```
|
|
310
|
+
|
|
302
311
|
##### `to_json_file(filepath: Union[str, Path], indent: int = 2, ensure_ascii: bool = False, metadata: Optional[Dict[str, Any]] = None) -> None`
|
|
303
312
|
|
|
304
313
|
Saves the pipeline to a JSON file. Useful for debugging, comparison, or versioning.
|
|
@@ -317,6 +326,17 @@ builder.to_json_file(
|
|
|
317
326
|
)
|
|
318
327
|
```
|
|
319
328
|
|
|
329
|
+
##### `compare_with(other: PipelineBuilder, context_lines: int = 3) -> str`
|
|
330
|
+
|
|
331
|
+
Returns a unified diff between two pipelines (useful for comparing “new” builder pipelines vs legacy/template pipelines).
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
legacy = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
335
|
+
new = PipelineBuilder().match({"status": "inactive"}).limit(10)
|
|
336
|
+
|
|
337
|
+
print(new.compare_with(legacy))
|
|
338
|
+
```
|
|
339
|
+
|
|
320
340
|
##### `build() -> List[Dict[str, Any]]`
|
|
321
341
|
|
|
322
342
|
Returns the complete pipeline as a list of stage dictionaries.
|
|
@@ -472,11 +492,39 @@ base = get_base_pipeline(user_id)
|
|
|
472
492
|
# Create multiple queries from cached base
|
|
473
493
|
recent = base.copy().sort({"createdAt": -1}).limit(10).build()
|
|
474
494
|
by_category = base.copy().match({"category": "tech"}).build()
|
|
475
|
-
with_stats = base.copy().group(
|
|
495
|
+
with_stats = base.copy().group("$category", {"count": {"$sum": 1}}).build()
|
|
476
496
|
|
|
477
497
|
# Base pipeline is safely cached and reused
|
|
478
498
|
```
|
|
479
499
|
|
|
500
|
+
## Best Practices
|
|
501
|
+
|
|
502
|
+
### Array `_id` after `$group`: prefer `$arrayElemAt` and materialize fields
|
|
503
|
+
|
|
504
|
+
If you use `$group` with an array `_id` (e.g. `["_idSeason", "_idTournament"]`), avoid relying on `$_id` later in the pipeline.
|
|
505
|
+
Instead, **extract elements with `$arrayElemAt` and store them into explicit fields**, then use those fields in subsequent stages.
|
|
506
|
+
|
|
507
|
+
```python
|
|
508
|
+
pipeline = (
|
|
509
|
+
PipelineBuilder()
|
|
510
|
+
.group(
|
|
511
|
+
group_by=["$idSeason", "$idTournament"],
|
|
512
|
+
accumulators={"idTeams": {"$addToSet": "$idTeam"}},
|
|
513
|
+
)
|
|
514
|
+
.project({
|
|
515
|
+
"idSeason": {"$arrayElemAt": ["$_id", 0]},
|
|
516
|
+
"idTournament": {"$arrayElemAt": ["$_id", 1]},
|
|
517
|
+
"idTeams": 1,
|
|
518
|
+
# Optional: preserve array _id explicitly if you really need it later
|
|
519
|
+
# "_id": "$_id",
|
|
520
|
+
})
|
|
521
|
+
.build()
|
|
522
|
+
)
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
This pattern reduces surprises and helps avoid errors like:
|
|
526
|
+
`$first's argument must be an array, but is object`.
|
|
527
|
+
|
|
480
528
|
#### Example: Pipeline Factories
|
|
481
529
|
|
|
482
530
|
```python
|
|
File without changes
|
|
@@ -6,6 +6,8 @@ Builder Pattern implementation for safe construction of MongoDB aggregation pipe
|
|
|
6
6
|
|
|
7
7
|
Author: seligoroff
|
|
8
8
|
"""
|
|
9
|
+
import copy
|
|
10
|
+
import difflib
|
|
9
11
|
import json
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
from typing import Any, Dict, List, Optional, Union
|
|
@@ -189,6 +191,29 @@ class PipelineBuilder:
|
|
|
189
191
|
"""
|
|
190
192
|
if not isinstance(accumulators, dict):
|
|
191
193
|
raise TypeError(f"accumulators must be a dict, got {type(accumulators)}")
|
|
194
|
+
|
|
195
|
+
# Guard against a common mistake: passing {"_id": ...} as group_by.
|
|
196
|
+
# group_by should be the expression that becomes the $group _id.
|
|
197
|
+
# If users pass {"_id": expr}, MongoDB will create nested _id and later
|
|
198
|
+
# expressions like $first: "$_id" may fail because $_id becomes an object.
|
|
199
|
+
if isinstance(group_by, dict) and set(group_by.keys()) == {"_id"}:
|
|
200
|
+
inner = group_by["_id"]
|
|
201
|
+
raise ValueError(
|
|
202
|
+
"Invalid group_by: you passed a dict wrapper {'_id': ...} to PipelineBuilder.group().\n"
|
|
203
|
+
"PipelineBuilder.group(group_by=...) expects the expression that becomes $group._id.\n"
|
|
204
|
+
"\n"
|
|
205
|
+
"Did you mean one of these?\n"
|
|
206
|
+
f"- builder.group(group_by={inner!r}, accumulators=...)\n"
|
|
207
|
+
f"- builder.group(group_by={inner!r}, accumulators={{...}}) # same, explicit\n"
|
|
208
|
+
"\n"
|
|
209
|
+
"Examples:\n"
|
|
210
|
+
"- Array _id: builder.group(group_by=['$idSeason', '$idTournament'], accumulators={...})\n"
|
|
211
|
+
"- Field path: builder.group(group_by='$category', accumulators={...})\n"
|
|
212
|
+
"- Composite key: builder.group(group_by={'category': '$category'}, accumulators={...})\n"
|
|
213
|
+
"\n"
|
|
214
|
+
"Why this matters: {'_id': expr} would create a nested _id object in MongoDB, and later\n"
|
|
215
|
+
"operators like $first/$last on '$_id' may fail with: \"$first's argument must be an array, but is object\"."
|
|
216
|
+
)
|
|
192
217
|
|
|
193
218
|
# Validate empty cases
|
|
194
219
|
# group_by can be None, empty string, empty dict, etc. - all are valid in MongoDB
|
|
@@ -779,7 +804,8 @@ class PipelineBuilder:
|
|
|
779
804
|
raise IndexError(
|
|
780
805
|
f"Index {index} out of range [0, {len(self._stages)}]"
|
|
781
806
|
)
|
|
782
|
-
|
|
807
|
+
# Return a deep copy so callers can safely mutate nested structures
|
|
808
|
+
return copy.deepcopy(self._stages[index])
|
|
783
809
|
|
|
784
810
|
def pretty_print(self, indent: int = 2, ensure_ascii: bool = False) -> str:
|
|
785
811
|
"""
|
|
@@ -811,6 +837,36 @@ class PipelineBuilder:
|
|
|
811
837
|
"""
|
|
812
838
|
return json.dumps(self._stages, indent=indent, ensure_ascii=ensure_ascii)
|
|
813
839
|
|
|
840
|
+
def pretty_print_stage(
|
|
841
|
+
self,
|
|
842
|
+
stage: Union[int, Dict[str, Any]],
|
|
843
|
+
indent: int = 2,
|
|
844
|
+
ensure_ascii: bool = False,
|
|
845
|
+
) -> str:
|
|
846
|
+
"""
|
|
847
|
+
Return a formatted JSON string representation of a single stage.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
stage: Stage index (0-based) or a stage dict
|
|
851
|
+
indent: Number of spaces for indentation (default: 2)
|
|
852
|
+
ensure_ascii: If False, non-ASCII characters are output as-is (default: False)
|
|
853
|
+
|
|
854
|
+
Returns:
|
|
855
|
+
Formatted JSON string of the stage
|
|
856
|
+
|
|
857
|
+
Raises:
|
|
858
|
+
TypeError: If stage is not an int or dict
|
|
859
|
+
IndexError: If stage is an int out of range
|
|
860
|
+
"""
|
|
861
|
+
if isinstance(stage, int):
|
|
862
|
+
stage_dict = self.get_stage_at(stage)
|
|
863
|
+
elif isinstance(stage, dict):
|
|
864
|
+
stage_dict = copy.deepcopy(stage)
|
|
865
|
+
else:
|
|
866
|
+
raise TypeError(f"stage must be an int index or a dict, got {type(stage)}")
|
|
867
|
+
|
|
868
|
+
return json.dumps(stage_dict, indent=indent, ensure_ascii=ensure_ascii)
|
|
869
|
+
|
|
814
870
|
def to_json_file(
|
|
815
871
|
self,
|
|
816
872
|
filepath: Union[str, Path],
|
|
@@ -855,6 +911,52 @@ class PipelineBuilder:
|
|
|
855
911
|
with open(filepath, "w", encoding="utf-8") as f:
|
|
856
912
|
json.dump(output, f, indent=indent, ensure_ascii=ensure_ascii)
|
|
857
913
|
|
|
914
|
+
def compare_with(self, other: "PipelineBuilder", context_lines: int = 3) -> str:
|
|
915
|
+
"""
|
|
916
|
+
Compare this pipeline with another pipeline and return a unified diff.
|
|
917
|
+
|
|
918
|
+
This is useful when migrating legacy pipelines (e.g., templates) to builder code.
|
|
919
|
+
|
|
920
|
+
Args:
|
|
921
|
+
other: Another PipelineBuilder instance to compare with
|
|
922
|
+
context_lines: Number of context lines in the unified diff (default: 3)
|
|
923
|
+
|
|
924
|
+
Returns:
|
|
925
|
+
Unified diff as a string. Returns "No differences." if pipelines are identical.
|
|
926
|
+
|
|
927
|
+
Raises:
|
|
928
|
+
TypeError: If other is not a PipelineBuilder
|
|
929
|
+
ValueError: If context_lines is negative
|
|
930
|
+
|
|
931
|
+
Example:
|
|
932
|
+
>>> legacy = PipelineBuilder().match({"a": 1})
|
|
933
|
+
>>> new = PipelineBuilder().match({"a": 2})
|
|
934
|
+
>>> print(new.compare_with(legacy))
|
|
935
|
+
"""
|
|
936
|
+
if not isinstance(other, PipelineBuilder):
|
|
937
|
+
raise TypeError(f"other must be a PipelineBuilder, got {type(other)}")
|
|
938
|
+
if not isinstance(context_lines, int):
|
|
939
|
+
raise TypeError(f"context_lines must be an int, got {type(context_lines)}")
|
|
940
|
+
if context_lines < 0:
|
|
941
|
+
raise ValueError("context_lines cannot be negative")
|
|
942
|
+
|
|
943
|
+
a = json.dumps(
|
|
944
|
+
self.build(),
|
|
945
|
+
indent=2,
|
|
946
|
+
ensure_ascii=False,
|
|
947
|
+
sort_keys=True,
|
|
948
|
+
).splitlines(keepends=True)
|
|
949
|
+
b = json.dumps(
|
|
950
|
+
other.build(),
|
|
951
|
+
indent=2,
|
|
952
|
+
ensure_ascii=False,
|
|
953
|
+
sort_keys=True,
|
|
954
|
+
).splitlines(keepends=True)
|
|
955
|
+
|
|
956
|
+
diff = difflib.unified_diff(a, b, fromfile="new", tofile="other", n=context_lines)
|
|
957
|
+
out = "".join(diff)
|
|
958
|
+
return out if out else "No differences."
|
|
959
|
+
|
|
858
960
|
def build(self) -> List[Dict[str, Any]]:
|
|
859
961
|
"""
|
|
860
962
|
Return the completed pipeline.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mongo-pipebuilder
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Type-safe, fluent MongoDB aggregation pipeline builder
|
|
5
5
|
Author-email: seligoroff <seligoroff@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -252,7 +252,7 @@ builder.prepend({"$match": {"deleted": False}})
|
|
|
252
252
|
Inserts a stage at a specific position (0-based index) in the pipeline.
|
|
253
253
|
|
|
254
254
|
```python
|
|
255
|
-
builder.match({"status": "active"}).group(
|
|
255
|
+
builder.match({"status": "active"}).group("$category", {"count": {"$sum": 1}})
|
|
256
256
|
builder.insert_at(1, {"$sort": {"name": 1}})
|
|
257
257
|
# Pipeline: [{"$match": {...}}, {"$sort": {...}}, {"$group": {...}}]
|
|
258
258
|
```
|
|
@@ -327,6 +327,15 @@ print(builder.pretty_print())
|
|
|
327
327
|
# ]
|
|
328
328
|
```
|
|
329
329
|
|
|
330
|
+
##### `pretty_print_stage(stage: Union[int, Dict[str, Any]], indent: int = 2, ensure_ascii: bool = False) -> str`
|
|
331
|
+
|
|
332
|
+
Returns a formatted JSON string representation of a single stage (by index or by dict).
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
builder = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
336
|
+
print(builder.pretty_print_stage(0)) # Prints the $match stage
|
|
337
|
+
```
|
|
338
|
+
|
|
330
339
|
##### `to_json_file(filepath: Union[str, Path], indent: int = 2, ensure_ascii: bool = False, metadata: Optional[Dict[str, Any]] = None) -> None`
|
|
331
340
|
|
|
332
341
|
Saves the pipeline to a JSON file. Useful for debugging, comparison, or versioning.
|
|
@@ -345,6 +354,17 @@ builder.to_json_file(
|
|
|
345
354
|
)
|
|
346
355
|
```
|
|
347
356
|
|
|
357
|
+
##### `compare_with(other: PipelineBuilder, context_lines: int = 3) -> str`
|
|
358
|
+
|
|
359
|
+
Returns a unified diff between two pipelines (useful for comparing “new” builder pipelines vs legacy/template pipelines).
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
legacy = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
363
|
+
new = PipelineBuilder().match({"status": "inactive"}).limit(10)
|
|
364
|
+
|
|
365
|
+
print(new.compare_with(legacy))
|
|
366
|
+
```
|
|
367
|
+
|
|
348
368
|
##### `build() -> List[Dict[str, Any]]`
|
|
349
369
|
|
|
350
370
|
Returns the complete pipeline as a list of stage dictionaries.
|
|
@@ -500,11 +520,39 @@ base = get_base_pipeline(user_id)
|
|
|
500
520
|
# Create multiple queries from cached base
|
|
501
521
|
recent = base.copy().sort({"createdAt": -1}).limit(10).build()
|
|
502
522
|
by_category = base.copy().match({"category": "tech"}).build()
|
|
503
|
-
with_stats = base.copy().group(
|
|
523
|
+
with_stats = base.copy().group("$category", {"count": {"$sum": 1}}).build()
|
|
504
524
|
|
|
505
525
|
# Base pipeline is safely cached and reused
|
|
506
526
|
```
|
|
507
527
|
|
|
528
|
+
## Best Practices
|
|
529
|
+
|
|
530
|
+
### Array `_id` after `$group`: prefer `$arrayElemAt` and materialize fields
|
|
531
|
+
|
|
532
|
+
If you use `$group` with an array `_id` (e.g. `["_idSeason", "_idTournament"]`), avoid relying on `$_id` later in the pipeline.
|
|
533
|
+
Instead, **extract elements with `$arrayElemAt` and store them into explicit fields**, then use those fields in subsequent stages.
|
|
534
|
+
|
|
535
|
+
```python
|
|
536
|
+
pipeline = (
|
|
537
|
+
PipelineBuilder()
|
|
538
|
+
.group(
|
|
539
|
+
group_by=["$idSeason", "$idTournament"],
|
|
540
|
+
accumulators={"idTeams": {"$addToSet": "$idTeam"}},
|
|
541
|
+
)
|
|
542
|
+
.project({
|
|
543
|
+
"idSeason": {"$arrayElemAt": ["$_id", 0]},
|
|
544
|
+
"idTournament": {"$arrayElemAt": ["$_id", 1]},
|
|
545
|
+
"idTeams": 1,
|
|
546
|
+
# Optional: preserve array _id explicitly if you really need it later
|
|
547
|
+
# "_id": "$_id",
|
|
548
|
+
})
|
|
549
|
+
.build()
|
|
550
|
+
)
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
This pattern reduces surprises and helps avoid errors like:
|
|
554
|
+
`$first's argument must be an array, but is object`.
|
|
555
|
+
|
|
508
556
|
#### Example: Pipeline Factories
|
|
509
557
|
|
|
510
558
|
```python
|
{mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/requires.txt
RENAMED
|
File without changes
|
{mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
@@ -535,3 +535,54 @@ class TestPipelineBuilderDebugMethods:
|
|
|
535
535
|
assert stage["$lookup"]["foreignField"] == "_id"
|
|
536
536
|
assert stage["$lookup"]["as"] == "user"
|
|
537
537
|
|
|
538
|
+
def test_compare_with_no_differences(self):
|
|
539
|
+
"""Test compare_with() returns 'No differences.' for identical pipelines."""
|
|
540
|
+
a = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
541
|
+
b = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
542
|
+
assert a.compare_with(b) == "No differences."
|
|
543
|
+
|
|
544
|
+
def test_compare_with_has_diff(self):
|
|
545
|
+
"""Test compare_with() returns unified diff when pipelines differ."""
|
|
546
|
+
legacy = PipelineBuilder().match({"status": "active"})
|
|
547
|
+
new = PipelineBuilder().match({"status": "inactive"})
|
|
548
|
+
diff = new.compare_with(legacy)
|
|
549
|
+
assert diff != "No differences."
|
|
550
|
+
assert "--- new" in diff
|
|
551
|
+
assert "+++ other" in diff
|
|
552
|
+
assert "\"active\"" in diff or "active" in diff
|
|
553
|
+
assert "\"inactive\"" in diff or "inactive" in diff
|
|
554
|
+
|
|
555
|
+
def test_compare_with_invalid_other_raises(self):
|
|
556
|
+
"""Test compare_with() validates 'other' argument."""
|
|
557
|
+
builder = PipelineBuilder().match({"status": "active"})
|
|
558
|
+
with pytest.raises(TypeError, match="other must be a PipelineBuilder"):
|
|
559
|
+
builder.compare_with([]) # type: ignore[arg-type]
|
|
560
|
+
|
|
561
|
+
def test_compare_with_negative_context_lines_raises(self):
|
|
562
|
+
"""Test compare_with() validates context_lines."""
|
|
563
|
+
a = PipelineBuilder().match({"status": "active"})
|
|
564
|
+
b = PipelineBuilder().match({"status": "inactive"})
|
|
565
|
+
with pytest.raises(ValueError, match="context_lines cannot be negative"):
|
|
566
|
+
a.compare_with(b, context_lines=-1)
|
|
567
|
+
|
|
568
|
+
def test_pretty_print_stage_by_index(self):
|
|
569
|
+
"""Test pretty_print_stage() with stage index."""
|
|
570
|
+
builder = PipelineBuilder().match({"status": "active"}).limit(10)
|
|
571
|
+
s = builder.pretty_print_stage(0)
|
|
572
|
+
parsed = json.loads(s)
|
|
573
|
+
assert parsed == {"$match": {"status": "active"}}
|
|
574
|
+
|
|
575
|
+
def test_pretty_print_stage_by_dict(self):
|
|
576
|
+
"""Test pretty_print_stage() with stage dict."""
|
|
577
|
+
builder = PipelineBuilder()
|
|
578
|
+
stage = {"$limit": 5}
|
|
579
|
+
s = builder.pretty_print_stage(stage, indent=4)
|
|
580
|
+
parsed = json.loads(s)
|
|
581
|
+
assert parsed == stage
|
|
582
|
+
|
|
583
|
+
def test_pretty_print_stage_invalid_type_raises(self):
|
|
584
|
+
"""Test pretty_print_stage() validates stage argument."""
|
|
585
|
+
builder = PipelineBuilder().match({"status": "active"})
|
|
586
|
+
with pytest.raises(TypeError, match="stage must be an int index or a dict"):
|
|
587
|
+
builder.pretty_print_stage("0") # type: ignore[arg-type]
|
|
588
|
+
|
|
@@ -95,7 +95,7 @@ class TestInsertAt:
|
|
|
95
95
|
def test_insert_at_middle(self):
|
|
96
96
|
"""Test insert_at() inserts in the middle."""
|
|
97
97
|
builder = PipelineBuilder()
|
|
98
|
-
builder.match({"status": "active"}).group(
|
|
98
|
+
builder.match({"status": "active"}).group("$category", {"count": {"$sum": 1}})
|
|
99
99
|
builder.insert_at(1, {"$sort": {"name": 1}})
|
|
100
100
|
|
|
101
101
|
pipeline = builder.build()
|
|
@@ -188,7 +188,7 @@ class TestInsertAt:
|
|
|
188
188
|
builder.match({"status": "active"})
|
|
189
189
|
builder.lookup("users", "userId", "_id", "user")
|
|
190
190
|
builder.unwind("user")
|
|
191
|
-
builder.group(
|
|
191
|
+
builder.group("$category", {"count": {"$sum": 1}})
|
|
192
192
|
|
|
193
193
|
# Insert $addFields before $group
|
|
194
194
|
builder.insert_at(3, {"$addFields": {"categoryUpper": {"$toUpper": "$category"}}})
|
|
@@ -224,7 +224,7 @@ class TestPrependAndInsertAtIntegration:
|
|
|
224
224
|
builder = PipelineBuilder()
|
|
225
225
|
builder.match({"status": "active"})
|
|
226
226
|
builder.lookup("users", "userId", "_id", "user")
|
|
227
|
-
builder.group(
|
|
227
|
+
builder.group("$category", {"count": {"$sum": 1}})
|
|
228
228
|
|
|
229
229
|
# Find position of $group and insert before it
|
|
230
230
|
stage_types = builder.get_stage_types()
|
|
File without changes
|
{mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_validation_existing.py
RENAMED
|
@@ -91,6 +91,12 @@ class TestGroupValidation:
|
|
|
91
91
|
with pytest.raises(TypeError, match="accumulators must be a dict"):
|
|
92
92
|
builder.group({}, 123)
|
|
93
93
|
|
|
94
|
+
def test_group_nested_id_wrapper_raises_error(self):
|
|
95
|
+
"""Test that group({'_id': ...}, ...) raises ValueError with guidance."""
|
|
96
|
+
builder = PipelineBuilder()
|
|
97
|
+
with pytest.raises(ValueError, match="Invalid group_by: you passed a dict wrapper"):
|
|
98
|
+
builder.group({"_id": ["$a", "$b"]}, {"count": {"$sum": 1}})
|
|
99
|
+
|
|
94
100
|
|
|
95
101
|
class TestUnwindValidation:
|
|
96
102
|
"""Tests for $unwind stage validation."""
|
|
File without changes
|