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.
Files changed (18) hide show
  1. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/LICENSE +0 -0
  2. {mongo_pipebuilder-0.3.0/src/mongo_pipebuilder.egg-info → mongo_pipebuilder-0.3.1}/PKG-INFO +51 -3
  3. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/README.md +50 -2
  4. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/pyproject.toml +1 -1
  5. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/setup.cfg +0 -0
  6. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder/__init__.py +1 -1
  7. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder/builder.py +103 -1
  8. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1/src/mongo_pipebuilder.egg-info}/PKG-INFO +51 -3
  9. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/SOURCES.txt +0 -0
  10. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/dependency_links.txt +0 -0
  11. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/requires.txt +0 -0
  12. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/src/mongo_pipebuilder.egg-info/top_level.txt +0 -0
  13. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder.py +0 -0
  14. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_debug.py +51 -0
  15. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_insert.py +3 -3
  16. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_validation.py +0 -0
  17. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_validation_existing.py +6 -0
  18. {mongo_pipebuilder-0.3.0 → mongo_pipebuilder-0.3.1}/tests/test_builder_validation_new.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mongo-pipebuilder
3
- Version: 0.3.0
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({"_id": "$category"}, {"count": {"$sum": 1}})
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({"_id": "$category"}, {"count": {"$sum": 1}}).build()
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({"_id": "$category"}, {"count": {"$sum": 1}})
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({"_id": "$category"}, {"count": {"$sum": 1}}).build()
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mongo-pipebuilder"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Type-safe, fluent MongoDB aggregation pipeline builder"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -9,6 +9,6 @@ Author: seligoroff
9
9
 
10
10
  from mongo_pipebuilder.builder import PipelineBuilder
11
11
 
12
- __version__ = "0.3.0"
12
+ __version__ = "0.3.1"
13
13
  __all__ = ["PipelineBuilder"]
14
14
 
@@ -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
- return self._stages[index].copy()
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.0
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({"_id": "$category"}, {"count": {"$sum": 1}})
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({"_id": "$category"}, {"count": {"$sum": 1}}).build()
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
@@ -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({"_id": "$category"}, {"count": {"$sum": 1}})
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({"_id": "$category"}, {"count": {"$sum": 1}})
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({"_id": "$category"}, {"count": {"$sum": 1}})
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()
@@ -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."""