mongo-pipebuilder 0.3.1__py3-none-any.whl → 0.4.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.
@@ -9,6 +9,6 @@ Author: seligoroff
9
9
 
10
10
  from mongo_pipebuilder.builder import PipelineBuilder
11
11
 
12
- __version__ = "0.3.1"
12
+ __version__ = "0.4.0"
13
13
  __all__ = ["PipelineBuilder"]
14
14
 
@@ -12,11 +12,8 @@ import json
12
12
  from pathlib import Path
13
13
  from typing import Any, Dict, List, Optional, Union
14
14
 
15
- # For compatibility with Python < 3.11
16
- try:
17
- from typing import Self
18
- except ImportError:
19
- from typing_extensions import Self
15
+ # For compatibility with Python < 3.11 and mypy with python_version 3.8
16
+ from typing_extensions import Self
20
17
 
21
18
 
22
19
  class PipelineBuilder:
@@ -29,16 +26,16 @@ class PipelineBuilder:
29
26
  def match(self, conditions: Dict[str, Any]) -> Self:
30
27
  """
31
28
  Add a $match stage for filtering documents.
32
-
29
+
33
30
  Args:
34
31
  conditions: Dictionary with filtering conditions
35
-
32
+
36
33
  Returns:
37
34
  Self for method chaining
38
-
35
+
39
36
  Raises:
40
37
  TypeError: If conditions is None or not a dictionary
41
-
38
+
42
39
  Example:
43
40
  >>> builder.match({"status": "active", "age": {"$gte": 18}})
44
41
  """
@@ -50,6 +47,29 @@ class PipelineBuilder:
50
47
  self._stages.append({"$match": conditions})
51
48
  return self
52
49
 
50
+ def match_expr(self, expr: Dict[str, Any]) -> Self:
51
+ """
52
+ Add a $match stage with $expr condition (expression-based filter).
53
+
54
+ Args:
55
+ expr: The expression for $expr (e.g. {"$eq": ["$id", "$$teamId"]}).
56
+
57
+ Returns:
58
+ Self for method chaining.
59
+
60
+ Raises:
61
+ TypeError: If expr is None or not a dict.
62
+
63
+ Example:
64
+ >>> builder.match_expr({"$eq": ["$id", "$$teamId"]})
65
+ """
66
+ if expr is None:
67
+ raise TypeError("expr cannot be None, use empty dict {} instead")
68
+ if not isinstance(expr, dict):
69
+ raise TypeError(f"expr must be a dict, got {type(expr)}")
70
+ self._stages.append({"$match": {"$expr": expr}})
71
+ return self
72
+
53
73
  def lookup(
54
74
  self,
55
75
  from_collection: str,
@@ -60,21 +80,21 @@ class PipelineBuilder:
60
80
  ) -> Self:
61
81
  """
62
82
  Add a $lookup stage for joining with another collection.
63
-
83
+
64
84
  Args:
65
85
  from_collection: Name of the collection to join with
66
86
  local_field: Field in the current collection
67
87
  foreign_field: Field in the target collection
68
88
  as_field: Name of the field for join results
69
89
  pipeline: Optional nested pipeline for filtering
70
-
90
+
71
91
  Returns:
72
92
  Self for method chaining
73
-
93
+
74
94
  Raises:
75
95
  TypeError: If pipeline is not None and not a list, or if string fields are not strings
76
96
  ValueError: If required string fields are empty
77
-
97
+
78
98
  Example:
79
99
  >>> builder.lookup(
80
100
  ... from_collection="users",
@@ -92,14 +112,14 @@ class PipelineBuilder:
92
112
  raise ValueError("foreign_field must be a non-empty string")
93
113
  if not isinstance(as_field, str) or not as_field:
94
114
  raise ValueError("as_field must be a non-empty string")
95
-
115
+
96
116
  # Validate pipeline
97
117
  if pipeline is not None:
98
118
  if not isinstance(pipeline, list):
99
119
  raise TypeError(f"pipeline must be a list, got {type(pipeline)}")
100
120
  if not all(isinstance(stage, dict) for stage in pipeline):
101
121
  raise TypeError("All pipeline stages must be dictionaries")
102
-
122
+
103
123
  lookup_stage: Dict[str, Any] = {
104
124
  "from": from_collection,
105
125
  "localField": local_field,
@@ -111,19 +131,133 @@ class PipelineBuilder:
111
131
  self._stages.append({"$lookup": lookup_stage})
112
132
  return self
113
133
 
134
+ def lookup_let(
135
+ self,
136
+ from_collection: str,
137
+ let: Dict[str, Any],
138
+ pipeline: Union[List[Dict[str, Any]], "PipelineBuilder"],
139
+ as_field: str,
140
+ ) -> Self:
141
+ """
142
+ Add a $lookup stage with let and pipeline (join by expression, variables from document).
143
+
144
+ Args:
145
+ from_collection: Name of the collection to join with.
146
+ let: Variables for the subpipeline (available in pipeline as $$key).
147
+ pipeline: Subpipeline as list of stages or PipelineBuilder (will call .build()).
148
+ as_field: Name of the field for join results.
149
+
150
+ Returns:
151
+ Self for method chaining.
152
+
153
+ Raises:
154
+ TypeError: If from_collection, as_field are not strings; if let is not a dict;
155
+ if pipeline is None or not a list/PipelineBuilder; if pipeline list has non-dict stages.
156
+ ValueError: If from_collection or as_field are empty; if pipeline list is empty.
157
+
158
+ Example:
159
+ >>> builder.lookup_let(
160
+ ... from_collection="teams",
161
+ ... let={"teamId": "$idTeam"},
162
+ ... pipeline=[{"$match": {"$expr": {"$eq": ["$_id", "$$teamId"]}}}],
163
+ ... as_field="team"
164
+ ... )
165
+ """
166
+ if not isinstance(from_collection, str):
167
+ raise TypeError("from_collection must be a string")
168
+ if not from_collection:
169
+ raise ValueError("from_collection must be a non-empty string")
170
+ if let is None:
171
+ raise TypeError("let cannot be None")
172
+ if not isinstance(let, dict):
173
+ raise TypeError("let must be a dict")
174
+ if not isinstance(as_field, str):
175
+ raise TypeError("as_field must be a string")
176
+ if not as_field:
177
+ raise ValueError("as_field must be a non-empty string")
178
+ if pipeline is None:
179
+ raise TypeError("pipeline cannot be None")
180
+ if isinstance(pipeline, PipelineBuilder):
181
+ pipeline = pipeline.build()
182
+ if not isinstance(pipeline, list):
183
+ raise TypeError("pipeline must be a list or PipelineBuilder")
184
+ if not pipeline:
185
+ raise ValueError("pipeline cannot be empty")
186
+ if not all(isinstance(stage, dict) for stage in pipeline):
187
+ raise TypeError("All pipeline stages must be dictionaries")
188
+
189
+ lookup_stage: Dict[str, Any] = {
190
+ "from": from_collection,
191
+ "let": let,
192
+ "pipeline": pipeline,
193
+ "as": as_field,
194
+ }
195
+ self._stages.append({"$lookup": lookup_stage})
196
+ return self
197
+
198
+ def union_with(
199
+ self,
200
+ coll: str,
201
+ pipeline: Optional[Union[List[Dict[str, Any]], "PipelineBuilder"]] = None,
202
+ ) -> Self:
203
+ """
204
+ Add a $unionWith stage to combine documents from another collection.
205
+
206
+ Merges all documents from the current pipeline with documents from the
207
+ given collection. If pipeline is provided, it is run on the other
208
+ collection before merging; you can pass a list of stages or a
209
+ PipelineBuilder (its .build() is used internally).
210
+
211
+ Args:
212
+ coll: Name of the collection to union with.
213
+ pipeline: Optional subpipeline (list of stages or PipelineBuilder).
214
+ Defaults to [].
215
+
216
+ Returns:
217
+ Self for method chaining.
218
+
219
+ Raises:
220
+ TypeError: If coll is not a string; if pipeline is not None and not
221
+ a list or PipelineBuilder; if pipeline list contains non-dict
222
+ stages.
223
+ ValueError: If coll is empty.
224
+
225
+ Example:
226
+ >>> builder.union_with("other_coll")
227
+ >>> builder.union_with("logs", [{"$match": {"level": "error"}}])
228
+ >>> sub = PipelineBuilder().match({"source": "x"}).project({"n": 1})
229
+ >>> builder.union_with("stats", sub)
230
+ """
231
+ if not isinstance(coll, str):
232
+ raise TypeError("coll must be a string")
233
+ if not coll:
234
+ raise ValueError("coll must be a non-empty string")
235
+ pipeline_list: List[Dict[str, Any]] = []
236
+ if pipeline is not None:
237
+ if isinstance(pipeline, PipelineBuilder):
238
+ pipeline_list = pipeline.build()
239
+ elif isinstance(pipeline, list):
240
+ if not all(isinstance(stage, dict) for stage in pipeline):
241
+ raise TypeError("All pipeline stages must be dictionaries")
242
+ pipeline_list = pipeline
243
+ else:
244
+ raise TypeError("pipeline must be a list or PipelineBuilder")
245
+ self._stages.append({"$unionWith": {"coll": coll, "pipeline": pipeline_list}})
246
+ return self
247
+
114
248
  def add_fields(self, fields: Dict[str, Any]) -> Self:
115
249
  """
116
250
  Add an $addFields stage for adding or modifying fields.
117
-
251
+
118
252
  Args:
119
253
  fields: Dictionary with new fields and their expressions
120
-
254
+
121
255
  Returns:
122
256
  Self for method chaining
123
-
257
+
124
258
  Raises:
125
259
  TypeError: If fields is not a dictionary
126
-
260
+
127
261
  Example:
128
262
  >>> builder.add_fields({
129
263
  ... "fullName": {"$concat": ["$firstName", " ", "$lastName"]}
@@ -140,16 +274,16 @@ class PipelineBuilder:
140
274
  def project(self, fields: Dict[str, Any]) -> Self:
141
275
  """
142
276
  Add a $project stage for reshaping documents.
143
-
277
+
144
278
  Args:
145
279
  fields: Dictionary with fields to include/exclude or transform
146
-
280
+
147
281
  Returns:
148
282
  Self for method chaining
149
-
283
+
150
284
  Raises:
151
285
  TypeError: If fields is not a dictionary
152
-
286
+
153
287
  Example:
154
288
  >>> builder.project({"name": 1, "email": 1, "_id": 0})
155
289
  """
@@ -164,21 +298,21 @@ class PipelineBuilder:
164
298
  def group(self, group_by: Union[str, Dict[str, Any], Any], accumulators: Dict[str, Any]) -> Self:
165
299
  """
166
300
  Add a $group stage for grouping documents.
167
-
301
+
168
302
  Args:
169
303
  group_by: Expression for grouping (becomes _id). Can be:
170
304
  - A string (field path, e.g., "$category")
171
305
  - A dict (composite key, e.g., {"category": "$category"})
172
306
  - Any other value (null, number, etc.)
173
307
  accumulators: Dictionary with accumulators (sum, avg, count, etc.)
174
-
308
+
175
309
  Returns:
176
310
  Self for method chaining
177
-
311
+
178
312
  Raises:
179
313
  TypeError: If accumulators is not a dictionary
180
314
  ValueError: If both group_by and accumulators are empty (when group_by is dict/str)
181
-
315
+
182
316
  Example:
183
317
  >>> builder.group(
184
318
  ... group_by="$category", # String field path
@@ -212,9 +346,10 @@ class PipelineBuilder:
212
346
  "- Composite key: builder.group(group_by={'category': '$category'}, accumulators={...})\n"
213
347
  "\n"
214
348
  "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\"."
349
+ "operators like $first/$last on '$_id' may fail with: "
350
+ "\"$first's argument must be an array, but is object\"."
216
351
  )
217
-
352
+
218
353
  # Validate empty cases
219
354
  # group_by can be None, empty string, empty dict, etc. - all are valid in MongoDB
220
355
  # But if it's a string and empty, or dict and empty, and accumulators is also empty,
@@ -225,7 +360,7 @@ class PipelineBuilder:
225
360
  elif isinstance(group_by, str):
226
361
  if not group_by and not accumulators:
227
362
  raise ValueError("group_by and accumulators cannot both be empty")
228
-
363
+
229
364
  group_stage = {"_id": group_by, **accumulators}
230
365
  self._stages.append({"$group": group_stage})
231
366
  return self
@@ -238,19 +373,19 @@ class PipelineBuilder:
238
373
  ) -> Self:
239
374
  """
240
375
  Add an $unwind stage for unwinding arrays.
241
-
376
+
242
377
  Args:
243
378
  path: Path to the array field
244
379
  preserve_null_and_empty_arrays: Preserve documents with null/empty arrays
245
380
  include_array_index: Name of the field for array element index
246
-
381
+
247
382
  Returns:
248
383
  Self for method chaining
249
-
384
+
250
385
  Raises:
251
386
  TypeError: If path is not a string
252
387
  ValueError: If path is empty
253
-
388
+
254
389
  Example:
255
390
  >>> builder.unwind("tags", preserve_null_and_empty_arrays=True)
256
391
  >>> builder.unwind("items", include_array_index="itemIndex")
@@ -259,7 +394,7 @@ class PipelineBuilder:
259
394
  raise TypeError(f"path must be a string, got {type(path)}")
260
395
  if not path:
261
396
  raise ValueError("path cannot be empty")
262
-
397
+
263
398
  unwind_stage: Dict[str, Any] = {"path": path}
264
399
  if preserve_null_and_empty_arrays:
265
400
  unwind_stage["preserveNullAndEmptyArrays"] = True
@@ -271,16 +406,16 @@ class PipelineBuilder:
271
406
  def sort(self, fields: Dict[str, int]) -> Self:
272
407
  """
273
408
  Add a $sort stage for sorting documents.
274
-
409
+
275
410
  Args:
276
411
  fields: Dictionary with fields and sort direction (1 - asc, -1 - desc)
277
-
412
+
278
413
  Returns:
279
414
  Self for method chaining
280
-
415
+
281
416
  Raises:
282
417
  TypeError: If fields is not a dictionary
283
-
418
+
284
419
  Example:
285
420
  >>> builder.sort({"createdAt": -1, "name": 1})
286
421
  """
@@ -295,17 +430,17 @@ class PipelineBuilder:
295
430
  def limit(self, limit: int) -> Self:
296
431
  """
297
432
  Add a $limit stage to limit the number of documents.
298
-
433
+
299
434
  Args:
300
435
  limit: Maximum number of documents
301
-
436
+
302
437
  Returns:
303
438
  Self for method chaining
304
-
439
+
305
440
  Raises:
306
441
  TypeError: If limit is not an integer
307
442
  ValueError: If limit is negative
308
-
443
+
309
444
  Example:
310
445
  >>> builder.limit(10)
311
446
  """
@@ -320,17 +455,17 @@ class PipelineBuilder:
320
455
  def skip(self, skip: int) -> Self:
321
456
  """
322
457
  Add a $skip stage to skip documents.
323
-
458
+
324
459
  Args:
325
460
  skip: Number of documents to skip
326
-
461
+
327
462
  Returns:
328
463
  Self for method chaining
329
-
464
+
330
465
  Raises:
331
466
  TypeError: If skip is not an integer
332
467
  ValueError: If skip is negative
333
-
468
+
334
469
  Example:
335
470
  >>> builder.skip(20)
336
471
  """
@@ -345,24 +480,24 @@ class PipelineBuilder:
345
480
  def unset(self, fields: Union[str, List[str]]) -> Self:
346
481
  """
347
482
  Add a $unset stage to remove fields from documents.
348
-
483
+
349
484
  Args:
350
485
  fields: Field name or list of field names to remove
351
-
486
+
352
487
  Returns:
353
488
  Self for method chaining
354
-
489
+
355
490
  Raises:
356
491
  TypeError: If fields is not a string or list of strings
357
492
  ValueError: If fields is empty
358
-
493
+
359
494
  Example:
360
495
  >>> builder.unset("temp_field")
361
496
  >>> builder.unset(["field1", "field2", "field3"])
362
497
  """
363
498
  if fields is None:
364
499
  raise TypeError("fields cannot be None")
365
-
500
+
366
501
  if isinstance(fields, str):
367
502
  if not fields:
368
503
  raise ValueError("fields cannot be an empty string")
@@ -378,23 +513,23 @@ class PipelineBuilder:
378
513
  self._stages.append({"$unset": fields if len(fields) > 1 else fields[0]})
379
514
  else:
380
515
  raise TypeError(f"fields must be a string or list of strings, got {type(fields)}")
381
-
516
+
382
517
  return self
383
518
 
384
519
  def replace_root(self, new_root: Dict[str, Any]) -> Self:
385
520
  """
386
521
  Add a $replaceRoot stage to replace the root document.
387
-
522
+
388
523
  Args:
389
524
  new_root: Expression for the new root document (must contain 'newRoot' key)
390
-
525
+
391
526
  Returns:
392
527
  Self for method chaining
393
-
528
+
394
529
  Raises:
395
530
  TypeError: If new_root is not a dictionary
396
531
  ValueError: If new_root is empty or missing 'newRoot' key
397
-
532
+
398
533
  Example:
399
534
  >>> builder.replace_root({"newRoot": "$embedded"})
400
535
  >>> builder.replace_root({"newRoot": {"$mergeObjects": ["$doc1", "$doc2"]}})
@@ -407,48 +542,48 @@ class PipelineBuilder:
407
542
  raise ValueError("new_root cannot be empty")
408
543
  if "newRoot" not in new_root:
409
544
  raise ValueError("new_root must contain 'newRoot' key")
410
-
545
+
411
546
  self._stages.append({"$replaceRoot": new_root})
412
547
  return self
413
548
 
414
549
  def replace_with(self, replacement: Any) -> Self:
415
550
  """
416
551
  Add a $replaceWith stage (alias for $replaceRoot in MongoDB 4.2+).
417
-
552
+
418
553
  Args:
419
554
  replacement: Expression for the replacement document
420
-
555
+
421
556
  Returns:
422
557
  Self for method chaining
423
-
558
+
424
559
  Raises:
425
560
  ValueError: If replacement is None
426
-
561
+
427
562
  Example:
428
563
  >>> builder.replace_with("$embedded")
429
564
  >>> builder.replace_with({"$mergeObjects": ["$doc1", "$doc2"]})
430
565
  """
431
566
  if replacement is None:
432
567
  raise ValueError("replacement cannot be None")
433
-
568
+
434
569
  self._stages.append({"$replaceWith": replacement})
435
570
  return self
436
571
 
437
572
  def facet(self, facets: Dict[str, List[Dict[str, Any]]]) -> Self:
438
573
  """
439
574
  Add a $facet stage for parallel execution of multiple sub-pipelines.
440
-
575
+
441
576
  Args:
442
577
  facets: Dictionary where keys are output field names and values are
443
578
  lists of pipeline stages for each sub-pipeline
444
-
579
+
445
580
  Returns:
446
581
  Self for method chaining
447
-
582
+
448
583
  Raises:
449
584
  TypeError: If facets is not a dictionary
450
585
  ValueError: If facets is empty or contains invalid values
451
-
586
+
452
587
  Example:
453
588
  >>> builder.facet({
454
589
  ... "items": [{"$skip": 10}, {"$limit": 20}],
@@ -461,31 +596,31 @@ class PipelineBuilder:
461
596
  raise TypeError(f"facets must be a dict, got {type(facets)}")
462
597
  if not facets:
463
598
  raise ValueError("facets cannot be empty")
464
-
599
+
465
600
  # Validate that all values are lists of dictionaries
466
601
  for key, value in facets.items():
467
602
  if not isinstance(value, list):
468
603
  raise TypeError(f"facet '{key}' must be a list, got {type(value)}")
469
604
  if not all(isinstance(stage, dict) for stage in value):
470
605
  raise TypeError(f"all stages in facet '{key}' must be dictionaries")
471
-
606
+
472
607
  self._stages.append({"$facet": facets})
473
608
  return self
474
609
 
475
610
  def count(self, field_name: str = "count") -> Self:
476
611
  """
477
612
  Add a $count stage to count documents.
478
-
613
+
479
614
  Args:
480
615
  field_name: Name of the field for the count result
481
-
616
+
482
617
  Returns:
483
618
  Self for method chaining
484
-
619
+
485
620
  Raises:
486
621
  TypeError: If field_name is not a string
487
622
  ValueError: If field_name is empty
488
-
623
+
489
624
  Example:
490
625
  >>> builder.match({"status": "active"}).count("active_count")
491
626
  """
@@ -495,26 +630,26 @@ class PipelineBuilder:
495
630
  raise TypeError(f"field_name must be a string, got {type(field_name)}")
496
631
  if not field_name:
497
632
  raise ValueError("field_name cannot be empty")
498
-
633
+
499
634
  self._stages.append({"$count": field_name})
500
635
  return self
501
636
 
502
637
  def set_field(self, fields: Dict[str, Any]) -> Self:
503
638
  """
504
639
  Add a $set stage (alias for $addFields in MongoDB 3.4+).
505
-
640
+
506
641
  Functionally equivalent to add_fields(), but $set is a more intuitive alias.
507
-
642
+
508
643
  Args:
509
644
  fields: Dictionary with fields and their values/expressions
510
-
645
+
511
646
  Returns:
512
647
  Self for method chaining
513
-
648
+
514
649
  Raises:
515
650
  TypeError: If fields is not a dictionary
516
651
  ValueError: If fields is empty
517
-
652
+
518
653
  Example:
519
654
  >>> builder.set_field({"status": "active", "updatedAt": "$$NOW"})
520
655
  """
@@ -525,20 +660,20 @@ class PipelineBuilder:
525
660
  if not fields:
526
661
  # Empty dict - valid case, skip (same as add_fields behavior)
527
662
  return self
528
-
663
+
529
664
  self._stages.append({"$set": fields})
530
665
  return self
531
666
 
532
667
  def add_stage(self, stage: Dict[str, Any]) -> Self:
533
668
  """
534
669
  Add an arbitrary pipeline stage for advanced use cases.
535
-
670
+
536
671
  Args:
537
672
  stage: Dictionary with an arbitrary MongoDB aggregation stage
538
-
673
+
539
674
  Returns:
540
675
  Self for method chaining
541
-
676
+
542
677
  Example:
543
678
  >>> builder.add_stage({
544
679
  ... "$facet": {
@@ -554,10 +689,10 @@ class PipelineBuilder:
554
689
  def __len__(self) -> int:
555
690
  """
556
691
  Return the number of stages in the pipeline.
557
-
692
+
558
693
  Returns:
559
694
  Number of stages
560
-
695
+
561
696
  Example:
562
697
  >>> builder = PipelineBuilder()
563
698
  >>> builder.match({"status": "active"}).limit(10)
@@ -569,10 +704,10 @@ class PipelineBuilder:
569
704
  def __repr__(self) -> str:
570
705
  """
571
706
  Return a string representation of the builder for debugging.
572
-
707
+
573
708
  Returns:
574
709
  String representation showing stage count and preview
575
-
710
+
576
711
  Example:
577
712
  >>> builder = PipelineBuilder()
578
713
  >>> builder.match({"status": "active"}).limit(10)
@@ -582,7 +717,7 @@ class PipelineBuilder:
582
717
  stages_count = len(self._stages)
583
718
  if stages_count == 0:
584
719
  return "PipelineBuilder(stages=0)"
585
-
720
+
586
721
  stage_types = [list(stage.keys())[0] for stage in self._stages[:3]]
587
722
  stages_preview = ", ".join(stage_types)
588
723
  if stages_count > 3:
@@ -592,10 +727,10 @@ class PipelineBuilder:
592
727
  def clear(self) -> Self:
593
728
  """
594
729
  Clear all stages from the pipeline.
595
-
730
+
596
731
  Returns:
597
732
  Self for method chaining
598
-
733
+
599
734
  Example:
600
735
  >>> builder = PipelineBuilder()
601
736
  >>> builder.match({"status": "active"}).clear()
@@ -608,10 +743,10 @@ class PipelineBuilder:
608
743
  def copy(self) -> "PipelineBuilder":
609
744
  """
610
745
  Create a copy of the builder with current stages.
611
-
746
+
612
747
  Returns:
613
748
  New PipelineBuilder instance with copied stages
614
-
749
+
615
750
  Example:
616
751
  >>> builder1 = PipelineBuilder().match({"status": "active"})
617
752
  >>> builder2 = builder1.copy()
@@ -628,17 +763,17 @@ class PipelineBuilder:
628
763
  def validate(self) -> bool:
629
764
  """
630
765
  Validate the pipeline before execution.
631
-
766
+
632
767
  Checks that the pipeline is not empty and has valid structure.
633
768
  Validates critical MongoDB rules:
634
769
  - $out and $merge stages must be the last stage in the pipeline
635
-
770
+
636
771
  Returns:
637
772
  True if pipeline is valid
638
-
773
+
639
774
  Raises:
640
775
  ValueError: If pipeline is empty or has validation errors
641
-
776
+
642
777
  Example:
643
778
  >>> builder = PipelineBuilder()
644
779
  >>> builder.match({"status": "active"}).validate()
@@ -648,20 +783,20 @@ class PipelineBuilder:
648
783
  """
649
784
  if not self._stages:
650
785
  raise ValueError("Pipeline cannot be empty")
651
-
786
+
652
787
  # Validate that $out and $merge are the last stages (critical MongoDB rule)
653
788
  stage_types = self.get_stage_types()
654
-
789
+
655
790
  # Check if $out or $merge exist
656
791
  has_out = "$out" in stage_types
657
792
  has_merge = "$merge" in stage_types
658
-
793
+
659
794
  if has_out and has_merge:
660
795
  raise ValueError(
661
796
  "Pipeline cannot contain both $out and $merge stages. "
662
797
  "Only one output stage is allowed."
663
798
  )
664
-
799
+
665
800
  # Check if $out or $merge exist and validate position
666
801
  for stage_name in ["$out", "$merge"]:
667
802
  if stage_name in stage_types:
@@ -671,16 +806,16 @@ class PipelineBuilder:
671
806
  f"{stage_name} stage must be the last stage in the pipeline. "
672
807
  f"Found at position {stage_index + 1} of {len(stage_types)}."
673
808
  )
674
-
809
+
675
810
  return True
676
811
 
677
812
  def get_stage_types(self) -> List[str]:
678
813
  """
679
814
  Get a list of stage types in the pipeline.
680
-
815
+
681
816
  Returns:
682
817
  List of stage type strings (e.g., ["$match", "$lookup", "$limit"])
683
-
818
+
684
819
  Example:
685
820
  >>> builder = PipelineBuilder()
686
821
  >>> builder.match({"status": "active"}).limit(10)
@@ -692,16 +827,16 @@ class PipelineBuilder:
692
827
  def has_stage(self, stage_type: str) -> bool:
693
828
  """
694
829
  Check if the pipeline contains a specific stage type.
695
-
830
+
696
831
  Args:
697
832
  stage_type: Type of stage to check (e.g., "$match", "$lookup")
698
-
833
+
699
834
  Returns:
700
835
  True if the stage type is present in the pipeline
701
-
836
+
702
837
  Raises:
703
838
  TypeError: If stage_type is not a string
704
-
839
+
705
840
  Example:
706
841
  >>> builder = PipelineBuilder()
707
842
  >>> builder.match({"status": "active"}).limit(10)
@@ -718,16 +853,16 @@ class PipelineBuilder:
718
853
  def prepend(self, stage: Dict[str, Any]) -> Self:
719
854
  """
720
855
  Add a stage at the beginning of the pipeline.
721
-
856
+
722
857
  Args:
723
858
  stage: Dictionary with a MongoDB aggregation stage
724
-
859
+
725
860
  Returns:
726
861
  Self for method chaining
727
-
862
+
728
863
  Raises:
729
864
  TypeError: If stage is not a dictionary
730
-
865
+
731
866
  Example:
732
867
  >>> builder = PipelineBuilder()
733
868
  >>> builder.match({"status": "active"})
@@ -746,18 +881,18 @@ class PipelineBuilder:
746
881
  def insert_at(self, position: int, stage: Dict[str, Any]) -> Self:
747
882
  """
748
883
  Insert a stage at a specific position in the pipeline.
749
-
884
+
750
885
  Args:
751
886
  position: Index where to insert (0-based)
752
887
  stage: Dictionary with a MongoDB aggregation stage to insert
753
-
888
+
754
889
  Returns:
755
890
  Self for method chaining
756
-
891
+
757
892
  Raises:
758
893
  TypeError: If stage is not a dictionary
759
894
  IndexError: If position is out of range [0, len(stages)]
760
-
895
+
761
896
  Example:
762
897
  >>> builder = PipelineBuilder()
763
898
  >>> builder.match({"status": "active"}).group({"_id": "$category"}, {})
@@ -771,28 +906,28 @@ class PipelineBuilder:
771
906
  raise TypeError(f"stage must be a dict, got {type(stage)}")
772
907
  if not stage:
773
908
  return self
774
-
909
+
775
910
  if position < 0 or position > len(self._stages):
776
911
  raise IndexError(
777
912
  f"Position {position} out of range [0, {len(self._stages)}]"
778
913
  )
779
-
914
+
780
915
  self._stages.insert(position, stage)
781
916
  return self
782
917
 
783
918
  def get_stage_at(self, index: int) -> Dict[str, Any]:
784
919
  """
785
920
  Get a specific stage from the pipeline by index.
786
-
921
+
787
922
  Args:
788
923
  index: Zero-based index of the stage to retrieve
789
-
924
+
790
925
  Returns:
791
926
  Dictionary representing the stage at the given index
792
-
927
+
793
928
  Raises:
794
929
  IndexError: If index is out of range
795
-
930
+
796
931
  Example:
797
932
  >>> builder = PipelineBuilder()
798
933
  >>> builder.match({"status": "active"}).limit(10)
@@ -810,16 +945,16 @@ class PipelineBuilder:
810
945
  def pretty_print(self, indent: int = 2, ensure_ascii: bool = False) -> str:
811
946
  """
812
947
  Return a formatted JSON string representation of the pipeline.
813
-
948
+
814
949
  Useful for debugging and understanding pipeline structure.
815
-
950
+
816
951
  Args:
817
952
  indent: Number of spaces for indentation (default: 2)
818
953
  ensure_ascii: If False, non-ASCII characters are output as-is (default: False)
819
-
954
+
820
955
  Returns:
821
956
  Formatted JSON string of the pipeline
822
-
957
+
823
958
  Example:
824
959
  >>> builder = PipelineBuilder()
825
960
  >>> builder.match({"status": "active"}).limit(10)
@@ -876,23 +1011,23 @@ class PipelineBuilder:
876
1011
  ) -> None:
877
1012
  """
878
1013
  Save the pipeline to a JSON file.
879
-
1014
+
880
1015
  Useful for debugging, comparison with other pipelines, or versioning.
881
-
1016
+
882
1017
  Args:
883
1018
  filepath: Path to the output JSON file (str or Path)
884
1019
  indent: Number of spaces for indentation (default: 2)
885
1020
  ensure_ascii: If False, non-ASCII characters are output as-is (default: False)
886
1021
  metadata: Optional metadata to include in the JSON file
887
-
1022
+
888
1023
  Raises:
889
1024
  IOError: If file cannot be written
890
-
1025
+
891
1026
  Example:
892
1027
  >>> builder = PipelineBuilder()
893
1028
  >>> builder.match({"status": "active"}).limit(10)
894
1029
  >>> builder.to_json_file("debug_pipeline.json")
895
-
1030
+
896
1031
  >>> # With metadata
897
1032
  >>> builder.to_json_file(
898
1033
  ... "pipeline.json",
@@ -901,33 +1036,33 @@ class PipelineBuilder:
901
1036
  """
902
1037
  filepath = Path(filepath)
903
1038
  filepath.parent.mkdir(parents=True, exist_ok=True)
904
-
1039
+
905
1040
  output: Dict[str, Any] = {
906
1041
  "pipeline": self._stages,
907
1042
  }
908
1043
  if metadata:
909
1044
  output["metadata"] = metadata
910
-
1045
+
911
1046
  with open(filepath, "w", encoding="utf-8") as f:
912
1047
  json.dump(output, f, indent=indent, ensure_ascii=ensure_ascii)
913
1048
 
914
1049
  def compare_with(self, other: "PipelineBuilder", context_lines: int = 3) -> str:
915
1050
  """
916
1051
  Compare this pipeline with another pipeline and return a unified diff.
917
-
1052
+
918
1053
  This is useful when migrating legacy pipelines (e.g., templates) to builder code.
919
-
1054
+
920
1055
  Args:
921
1056
  other: Another PipelineBuilder instance to compare with
922
1057
  context_lines: Number of context lines in the unified diff (default: 3)
923
-
1058
+
924
1059
  Returns:
925
1060
  Unified diff as a string. Returns "No differences." if pipelines are identical.
926
-
1061
+
927
1062
  Raises:
928
1063
  TypeError: If other is not a PipelineBuilder
929
1064
  ValueError: If context_lines is negative
930
-
1065
+
931
1066
  Example:
932
1067
  >>> legacy = PipelineBuilder().match({"a": 1})
933
1068
  >>> new = PipelineBuilder().match({"a": 2})
@@ -960,10 +1095,10 @@ class PipelineBuilder:
960
1095
  def build(self) -> List[Dict[str, Any]]:
961
1096
  """
962
1097
  Return the completed pipeline.
963
-
1098
+
964
1099
  Returns:
965
1100
  List of dictionaries with aggregation pipeline stages
966
-
1101
+
967
1102
  Example:
968
1103
  >>> pipeline = builder.build()
969
1104
  >>> collection.aggregate(pipeline)
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mongo-pipebuilder
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Type-safe, fluent MongoDB aggregation pipeline builder
5
5
  Author-email: seligoroff <seligoroff@gmail.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/seligoroff/mongo-pipebuilder
8
8
  Project-URL: Documentation, https://github.com/seligoroff/mongo-pipebuilder#readme
9
9
  Project-URL: Repository, https://github.com/seligoroff/mongo-pipebuilder
@@ -11,7 +11,6 @@ Project-URL: Issues, https://github.com/seligoroff/mongo-pipebuilder/issues
11
11
  Keywords: mongodb,aggregation,pipeline,builder,query
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
14
- Classifier: License :: OSI Approved :: MIT License
15
14
  Classifier: Programming Language :: Python :: 3
16
15
  Classifier: Programming Language :: Python :: 3.8
17
16
  Classifier: Programming Language :: Python :: 3.9
@@ -98,6 +97,15 @@ Adds a `$match` stage to filter documents.
98
97
  .match({"status": "active", "age": {"$gte": 18}})
99
98
  ```
100
99
 
100
+ ##### `match_expr(expr: Dict[str, Any]) -> Self`
101
+
102
+ Adds a `$match` stage with an `$expr` condition (expression-based filter; useful for comparing fields or using variables from `let` in subpipelines).
103
+
104
+ ```python
105
+ .match_expr({"$eq": ["$id", "$$teamId"]})
106
+ .match_expr({"$and": [{"$gte": ["$field", "$other"]}, {"$lte": ["$score", 100]}]})
107
+ ```
108
+
101
109
  ##### `lookup(from_collection: str, local_field: str, foreign_field: str, as_field: str, pipeline: Optional[List[Dict[str, Any]]] = None) -> Self`
102
110
 
103
111
  Adds a `$lookup` stage to join with another collection.
@@ -112,6 +120,43 @@ Adds a `$lookup` stage to join with another collection.
112
120
  )
113
121
  ```
114
122
 
123
+ ##### `lookup_let(from_collection: str, let: Dict[str, Any], pipeline: Union[List[Dict[str, Any]], PipelineBuilder], as_field: str) -> Self`
124
+
125
+ Adds a `$lookup` stage with `let` and `pipeline` (join by expression; variables from the current document are available in the subpipeline as `$$var`). Use this when the join condition is an expression (e.g. `$expr`) rather than equality of two fields.
126
+
127
+ ```python
128
+ # With list of stages
129
+ .lookup_let(
130
+ from_collection="teams",
131
+ let={"teamId": "$idTeam"},
132
+ pipeline=[
133
+ {"$match": {"$expr": {"$eq": ["$_id", "$$teamId"]}}},
134
+ {"$project": {"name": 1, "_id": 0}}
135
+ ],
136
+ as_field="team"
137
+ )
138
+
139
+ # With PipelineBuilder for the subpipeline (optionally using match_expr)
140
+ sub = PipelineBuilder().match_expr({"$eq": ["$_id", "$$teamId"]}).project({"name": 1, "_id": 0})
141
+ .lookup_let("teams", {"teamId": "$idTeam"}, sub, as_field="team")
142
+ ```
143
+
144
+ ##### `union_with(coll: str, pipeline: Optional[Union[List[Dict[str, Any]], PipelineBuilder]] = None) -> Self`
145
+
146
+ Adds a `$unionWith` stage to combine documents from the current pipeline with documents from another collection. Optionally runs a subpipeline on the other collection before merging.
147
+
148
+ ```python
149
+ # Union with another collection (no subpipeline)
150
+ .union_with("other_coll")
151
+
152
+ # With subpipeline as list of stages
153
+ .union_with("logs", [{"$match": {"level": "error"}}, {"$limit": 100}])
154
+
155
+ # With PipelineBuilder for the subpipeline
156
+ sub = PipelineBuilder().match({"source": "individual"}).project({"name": 1})
157
+ .union_with("sso_individual_statistics", sub)
158
+ ```
159
+
115
160
  ##### `add_fields(fields: Dict[str, Any]) -> Self`
116
161
 
117
162
  Adds a `$addFields` stage to add or modify fields.
@@ -411,6 +456,31 @@ pipeline = (
411
456
  )
412
457
  ```
413
458
 
459
+ ### Lookup by expression (lookup_let)
460
+
461
+ When the join condition is an expression (e.g. `$expr`) rather than matching two fields, use `lookup_let`. The subpipeline can be built with `match_expr()`:
462
+
463
+ ```python
464
+ sub = (
465
+ PipelineBuilder()
466
+ .match_expr({"$eq": ["$_id", "$$teamId"]})
467
+ .project({"name": 1, "slug": 1, "_id": 0})
468
+ )
469
+ pipeline = (
470
+ PipelineBuilder()
471
+ .match({"status": "active"})
472
+ .lookup_let(
473
+ from_collection="teams",
474
+ let={"teamId": "$idTeam"},
475
+ pipeline=sub,
476
+ as_field="team"
477
+ )
478
+ .unwind("team", preserve_null_and_empty_arrays=True)
479
+ .project({"title": 1, "teamName": "$team.name"})
480
+ .build()
481
+ )
482
+ ```
483
+
414
484
  ### Aggregation with Grouping
415
485
 
416
486
  ```python
@@ -0,0 +1,7 @@
1
+ mongo_pipebuilder/__init__.py,sha256=3iWmQvRAT2QZHXURN9AHoMPn-7FjwH9ig8QyTUCVLh4,336
2
+ mongo_pipebuilder/builder.py,sha256=_c-5uuNwWJigKzzIcOXXkPY9oD_UOC0lomhx03yJz9U,38834
3
+ mongo_pipebuilder-0.4.0.dist-info/licenses/LICENSE,sha256=-ZkZpDLHDQAc-YBIojJ6eDsMwxwx5pRuQz3RHnl9Y8w,1104
4
+ mongo_pipebuilder-0.4.0.dist-info/METADATA,sha256=IAtv0lDGEIiQ-OlFLn1LR6fDFtgC1xj_PSH3Ak31lE4,20002
5
+ mongo_pipebuilder-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
6
+ mongo_pipebuilder-0.4.0.dist-info/top_level.txt,sha256=wLn7H_v-qaNIws5FeBbKPZBCmYFYgFEhPaLjoCWcisc,18
7
+ mongo_pipebuilder-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,7 +0,0 @@
1
- mongo_pipebuilder/__init__.py,sha256=dvekji4j1j9v5MzJOJIqyO2znWVia1opBn8Y1Sc_Y3k,336
2
- mongo_pipebuilder/builder.py,sha256=Fz7oUiB9FpqnIwnGgamof2ZEBaUGjfYSuB7mYCJO9Qc,34731
3
- mongo_pipebuilder-0.3.1.dist-info/licenses/LICENSE,sha256=-ZkZpDLHDQAc-YBIojJ6eDsMwxwx5pRuQz3RHnl9Y8w,1104
4
- mongo_pipebuilder-0.3.1.dist-info/METADATA,sha256=hYFQkwz1xtJK-MPAV2Vp4PuwKLvC7CLc5U-emp4yOzw,17478
5
- mongo_pipebuilder-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- mongo_pipebuilder-0.3.1.dist-info/top_level.txt,sha256=wLn7H_v-qaNIws5FeBbKPZBCmYFYgFEhPaLjoCWcisc,18
7
- mongo_pipebuilder-0.3.1.dist-info/RECORD,,