mongodb-typesafe-aggregation 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.
Files changed (34) hide show
  1. mongodb_typesafe_aggregation/__init__.py +59 -0
  2. mongodb_typesafe_aggregation/builder.py +538 -0
  3. mongodb_typesafe_aggregation/constants.py +5 -0
  4. mongodb_typesafe_aggregation/errors.py +30 -0
  5. mongodb_typesafe_aggregation/py.typed +0 -0
  6. mongodb_typesafe_aggregation/schema.py +128 -0
  7. mongodb_typesafe_aggregation/stages/__init__.py +47 -0
  8. mongodb_typesafe_aggregation/stages/_utils.py +21 -0
  9. mongodb_typesafe_aggregation/stages/add_fields.py +25 -0
  10. mongodb_typesafe_aggregation/stages/bucket.py +55 -0
  11. mongodb_typesafe_aggregation/stages/bucket_auto.py +47 -0
  12. mongodb_typesafe_aggregation/stages/count.py +25 -0
  13. mongodb_typesafe_aggregation/stages/facet.py +25 -0
  14. mongodb_typesafe_aggregation/stages/graph_lookup.py +65 -0
  15. mongodb_typesafe_aggregation/stages/group.py +29 -0
  16. mongodb_typesafe_aggregation/stages/limit.py +24 -0
  17. mongodb_typesafe_aggregation/stages/lookup.py +59 -0
  18. mongodb_typesafe_aggregation/stages/match.py +24 -0
  19. mongodb_typesafe_aggregation/stages/merge.py +57 -0
  20. mongodb_typesafe_aggregation/stages/project.py +55 -0
  21. mongodb_typesafe_aggregation/stages/redact.py +24 -0
  22. mongodb_typesafe_aggregation/stages/replace_root.py +24 -0
  23. mongodb_typesafe_aggregation/stages/set_stage.py +27 -0
  24. mongodb_typesafe_aggregation/stages/skip.py +24 -0
  25. mongodb_typesafe_aggregation/stages/sort.py +36 -0
  26. mongodb_typesafe_aggregation/stages/union_with.py +26 -0
  27. mongodb_typesafe_aggregation/stages/unset_stage.py +26 -0
  28. mongodb_typesafe_aggregation/stages/unwind.py +47 -0
  29. mongodb_typesafe_aggregation/types/__init__.py +11 -0
  30. mongodb_typesafe_aggregation/types/filter.py +203 -0
  31. mongodb_typesafe_aggregation-0.1.0.dist-info/METADATA +465 -0
  32. mongodb_typesafe_aggregation-0.1.0.dist-info/RECORD +34 -0
  33. mongodb_typesafe_aggregation-0.1.0.dist-info/WHEEL +4 -0
  34. mongodb_typesafe_aggregation-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,59 @@
1
+ """A type-safe, fluent builder for MongoDB aggregation pipelines."""
2
+
3
+ from mongodb_typesafe_aggregation.builder import PipelineBuilder
4
+ from mongodb_typesafe_aggregation.errors import UnknownFieldError
5
+ from mongodb_typesafe_aggregation.stages import (
6
+ add_fields_stage,
7
+ bucket_auto_stage,
8
+ bucket_stage,
9
+ count_stage,
10
+ facet_stage,
11
+ graph_lookup_stage,
12
+ group_stage,
13
+ limit_stage,
14
+ lookup_stage,
15
+ match_stage,
16
+ merge_stage,
17
+ project_stage,
18
+ redact_stage,
19
+ replace_root_stage,
20
+ set_stage,
21
+ skip_stage,
22
+ sort_stage,
23
+ union_with_stage,
24
+ unset_stage,
25
+ unwind_stage,
26
+ )
27
+ from mongodb_typesafe_aggregation.types import FilterOperator, Pipeline, Stage
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ # `filter` is importable from `mongodb_typesafe_aggregation.types.filter`; it is left out here
32
+ # so that `from mongodb_typesafe_aggregation import *` does not shadow the builtin.
33
+ __all__ = [
34
+ "FilterOperator",
35
+ "Pipeline",
36
+ "PipelineBuilder",
37
+ "Stage",
38
+ "UnknownFieldError",
39
+ "add_fields_stage",
40
+ "bucket_auto_stage",
41
+ "bucket_stage",
42
+ "count_stage",
43
+ "facet_stage",
44
+ "graph_lookup_stage",
45
+ "group_stage",
46
+ "limit_stage",
47
+ "lookup_stage",
48
+ "match_stage",
49
+ "merge_stage",
50
+ "project_stage",
51
+ "redact_stage",
52
+ "replace_root_stage",
53
+ "set_stage",
54
+ "skip_stage",
55
+ "sort_stage",
56
+ "union_with_stage",
57
+ "unset_stage",
58
+ "unwind_stage",
59
+ ]
@@ -0,0 +1,538 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from collections.abc import Callable, Iterable, Mapping, Sequence
5
+ from typing import Any, Generic, TypeAlias, TypeVar, cast, overload
6
+
7
+ from mongodb_typesafe_aggregation.constants import DEFAULT_COUNT_FIELD, ID_FIELD
8
+ from mongodb_typesafe_aggregation.schema import (
9
+ FieldSet,
10
+ check_expression,
11
+ check_field,
12
+ check_query,
13
+ field_root,
14
+ fields_of,
15
+ )
16
+ from mongodb_typesafe_aggregation.stages import (
17
+ Granularity,
18
+ SortOrder,
19
+ WhenMatched,
20
+ WhenNotMatched,
21
+ add_fields_stage,
22
+ bucket_auto_stage,
23
+ bucket_stage,
24
+ count_stage,
25
+ facet_stage,
26
+ graph_lookup_stage,
27
+ group_stage,
28
+ limit_stage,
29
+ lookup_stage,
30
+ match_stage,
31
+ merge_stage,
32
+ project_stage,
33
+ redact_stage,
34
+ replace_root_stage,
35
+ set_stage,
36
+ skip_stage,
37
+ sort_stage,
38
+ union_with_stage,
39
+ unset_stage,
40
+ unwind_stage,
41
+ )
42
+ from mongodb_typesafe_aggregation.stages.bucket import _UNSET
43
+ from mongodb_typesafe_aggregation.stages.project import (
44
+ is_exclusion,
45
+ is_inclusion,
46
+ is_nested_projection,
47
+ )
48
+ from mongodb_typesafe_aggregation.types import Pipeline, Stage
49
+
50
+ T = TypeVar("T")
51
+ G = TypeVar("G")
52
+
53
+ SubPipeline: TypeAlias = "Sequence[Mapping[str, Any]] | PipelineBuilder[Any]"
54
+ """A list of stages, or a builder whose ``build()`` result is used."""
55
+
56
+ _SAME: Any = object()
57
+ """Marker for stages that keep the current fields."""
58
+
59
+
60
+ class PipelineBuilder(Generic[T]):
61
+ """Builder for constructing MongoDB aggregation pipelines.
62
+
63
+ Every method returns a new builder and leaves the original untouched, so a partially
64
+ built pipeline can safely be reused as the base of several others.
65
+
66
+ The builder tracks which fields the documents have after each stage, so field checks keep
67
+ working after stages that change the shape: after ``group()``, only the group's output
68
+ fields are known. Stages that add fields (``lookup``, ``add_fields``, ...) keep the type
69
+ parameter ``T``; stages that replace the shape (``group``, ``project``, ...) return
70
+ ``PipelineBuilder[Any]``. Use ``cast()`` to declare the new shape.
71
+
72
+ Args:
73
+ schema: The document class of the collection: a ``TypedDict``, dataclass, Pydantic
74
+ model or annotated class. Field names used in stages are checked against it, and
75
+ ``UnknownFieldError`` is raised on a typo. ``None`` skips field checks.
76
+ stages: Stages to start from. They are not checked.
77
+ strict: ``False`` keeps the static typing but skips the runtime field checks.
78
+
79
+ Example:
80
+ >>> class Order(TypedDict):
81
+ ... status: str
82
+ ... total: float
83
+ >>> PipelineBuilder(Order).match(status="pending").sort(total=-1).limit(20).build()
84
+ [{'$match': {'status': 'pending'}}, {'$sort': {'total': -1}}, {'$limit': 20}]
85
+ """
86
+
87
+ @overload
88
+ def __init__(
89
+ self: PipelineBuilder[Any],
90
+ schema: None = None,
91
+ stages: Iterable[Mapping[str, Any]] = (),
92
+ *,
93
+ strict: bool = True,
94
+ ) -> None: ...
95
+
96
+ @overload
97
+ def __init__(
98
+ self,
99
+ schema: type[T],
100
+ stages: Iterable[Mapping[str, Any]] = (),
101
+ *,
102
+ strict: bool = True,
103
+ ) -> None: ...
104
+
105
+ def __init__(
106
+ self,
107
+ schema: type[T] | None = None,
108
+ stages: Iterable[Mapping[str, Any]] = (),
109
+ *,
110
+ strict: bool = True,
111
+ ) -> None:
112
+ self._stages: Pipeline = [dict(stage) for stage in stages]
113
+ self._strict = strict
114
+ self._fields: FieldSet = fields_of(schema) if strict else None
115
+ self._closed = False
116
+
117
+ def __repr__(self) -> str:
118
+ return f"{type(self).__name__}({self._stages!r})"
119
+
120
+ def build(self) -> Pipeline:
121
+ """Builds the final aggregation pipeline.
122
+
123
+ Returns:
124
+ A deep copy of the stages, so changing the result never affects the builder.
125
+ """
126
+ return copy.deepcopy(self._stages)
127
+
128
+ def cast(self, schema: type[G]) -> PipelineBuilder[G]:
129
+ """Declares the shape of the documents at this point of the pipeline.
130
+
131
+ Field checks in the following stages use ``schema``. This is the equivalent of the
132
+ TypeScript ``group<G>()`` type argument.
133
+
134
+ Example:
135
+ >>> class StatusCount(TypedDict):
136
+ ... _id: str
137
+ ... orders: int
138
+ >>> (
139
+ ... PipelineBuilder(Order)
140
+ ... .group({"_id": "$status", "orders": {"$sum": 1}})
141
+ ... .cast(StatusCount)
142
+ ... )
143
+ """
144
+ builder = copy.copy(self)
145
+ builder._fields = fields_of(schema) if self._strict else None
146
+ return cast("PipelineBuilder[G]", builder)
147
+
148
+ def raw(self, stage: Mapping[str, Any]) -> PipelineBuilder[T]:
149
+ """Adds a stage as-is. The escape hatch for stages the builder does not cover.
150
+
151
+ The stage is not checked, and the document shape is assumed unchanged. If the stage
152
+ changes it, follow with ``cast()`` to declare the new shape.
153
+ """
154
+ return self._add(dict(stage))
155
+
156
+ def match(self, query: Mapping[str, Any] | None = None, /, **fields: Any) -> PipelineBuilder[T]:
157
+ """Adds a ``$match`` stage.
158
+
159
+ Conditions can be given as a mapping, as keyword arguments, or both. Use the mapping for
160
+ dotted paths and for operators such as ``$or`` and ``$expr``.
161
+
162
+ Raises:
163
+ UnknownFieldError: A field is not declared on the current schema.
164
+
165
+ Example:
166
+ >>> builder.match(status="pending", price=filter(float).gt(700))
167
+ >>> builder.match({"supplier.region": "US", "$or": [{"stock": 0}, {"active": False}]})
168
+ """
169
+ stage = match_stage(_combine(query, fields))
170
+ check_query(stage["$match"], self._fields)
171
+ return self._add(stage)
172
+
173
+ def lookup(
174
+ self,
175
+ from_: str,
176
+ *,
177
+ as_: str,
178
+ local_field: str | None = None,
179
+ foreign_field: str | None = None,
180
+ let: Mapping[str, Any] | None = None,
181
+ pipeline: SubPipeline | None = None,
182
+ ) -> PipelineBuilder[T]:
183
+ """Adds a ``$lookup`` stage, joining ``from_`` into the array field ``as_``.
184
+
185
+ ``local_field`` and the expressions in ``let`` are checked against the current
186
+ schema; ``foreign_field`` and ``pipeline`` refer to the other collection.
187
+
188
+ Example:
189
+ >>> builder.lookup("orders", local_field="_id", foreign_field="productId", as_="orders")
190
+ """
191
+ stage = lookup_stage(
192
+ from_,
193
+ as_=as_,
194
+ local_field=local_field,
195
+ foreign_field=foreign_field,
196
+ let=let,
197
+ pipeline=None if pipeline is None else _sub_pipeline(pipeline),
198
+ )
199
+ if local_field is not None:
200
+ check_field(local_field, self._fields, "$lookup")
201
+ check_expression(stage["$lookup"].get("let"), self._fields, "$lookup")
202
+ return self._add(stage, fields=self._extended([as_]))
203
+
204
+ def group(
205
+ self, spec: Mapping[str, Any] | None = None, /, **fields: Any
206
+ ) -> PipelineBuilder[Any]:
207
+ """Adds a ``$group`` stage. The documents now have only ``_id`` and the output fields.
208
+
209
+ Example:
210
+ >>> builder.group(_id="$status", totalOrders={"$sum": 1})
211
+ """
212
+ stage = group_stage(_combine(spec, fields))
213
+ check_expression(list(stage["$group"].values()), self._fields, "$group")
214
+ return self._add(stage, fields=self._replaced(stage["$group"]))
215
+
216
+ def add_fields(
217
+ self, spec: Mapping[str, Any] | None = None, /, **fields: Any
218
+ ) -> PipelineBuilder[T]:
219
+ """Adds an ``$addFields`` stage.
220
+
221
+ Example:
222
+ >>> builder.add_fields(orderedQuantity={"$sum": "$orders.quantity"})
223
+ """
224
+ stage = add_fields_stage(_combine(spec, fields))
225
+ check_expression(list(stage["$addFields"].values()), self._fields, "$addFields")
226
+ return self._add(stage, fields=self._extended(stage["$addFields"]))
227
+
228
+ def set(self, spec: Mapping[str, Any] | None = None, /, **fields: Any) -> PipelineBuilder[T]:
229
+ """Adds a ``$set`` stage, an alias of ``$addFields`` (MongoDB 4.2+)."""
230
+ stage = set_stage(_combine(spec, fields))
231
+ check_expression(list(stage["$set"].values()), self._fields, "$set")
232
+ return self._add(stage, fields=self._extended(stage["$set"]))
233
+
234
+ def project(
235
+ self, spec: Mapping[str, Any] | None = None, /, **fields: Any
236
+ ) -> PipelineBuilder[Any]:
237
+ """Adds a ``$project`` stage.
238
+
239
+ Use ``1``/``True`` to include a field, ``0``/``False`` to exclude it, or an expression
240
+ to compute it. The documents now have only the projected fields.
241
+
242
+ Example:
243
+ >>> builder.project(_id=0, name=1, remainingStock={"$subtract": ["$stock", 1]})
244
+ """
245
+ stage = project_stage(_combine(spec, fields))
246
+ projection: dict[str, Any] = stage["$project"]
247
+
248
+ for key, value in projection.items():
249
+ if is_inclusion(value) or is_exclusion(value) or is_nested_projection(value):
250
+ check_field(key, self._fields, "$project")
251
+ else:
252
+ check_expression(value, self._fields, "$project")
253
+
254
+ excluded = {key for key, value in projection.items() if is_exclusion(value)}
255
+ if set(projection) - excluded - {ID_FIELD}:
256
+ kept = {field_root(key) for key in projection if key not in excluded}
257
+ shape = self._replaced(kept, with_id=ID_FIELD not in excluded)
258
+ else:
259
+ shape = self._removed(excluded)
260
+ return self._add(stage, fields=shape)
261
+
262
+ def sort(
263
+ self, spec: Mapping[str, SortOrder] | None = None, /, **fields: SortOrder
264
+ ) -> PipelineBuilder[T]:
265
+ """Adds a ``$sort`` stage. Fields are sorted in the order given.
266
+
267
+ Example:
268
+ >>> builder.sort(price=-1, name=1)
269
+ """
270
+ stage = sort_stage(_combine(spec, fields))
271
+ for field in stage["$sort"]:
272
+ check_field(field, self._fields, "$sort")
273
+ return self._add(stage)
274
+
275
+ def skip(self, count: int) -> PipelineBuilder[T]:
276
+ """Adds a ``$skip`` stage."""
277
+ return self._add(skip_stage(count))
278
+
279
+ def limit(self, count: int) -> PipelineBuilder[T]:
280
+ """Adds a ``$limit`` stage."""
281
+ return self._add(limit_stage(count))
282
+
283
+ def facet(
284
+ self,
285
+ spec: Mapping[str, SubPipeline | Callable[[PipelineBuilder[T]], PipelineBuilder[Any]]]
286
+ | None = None,
287
+ /,
288
+ **pipelines: SubPipeline | Callable[[PipelineBuilder[T]], PipelineBuilder[Any]],
289
+ ) -> PipelineBuilder[Any]:
290
+ """Adds a ``$facet`` stage, running several pipelines on the same documents.
291
+
292
+ Each pipeline can be a list of stages, a builder, or a function that receives a fresh
293
+ builder for the current documents, so its stages are checked against the current
294
+ schema. The documents now have one array field per pipeline.
295
+
296
+ Example:
297
+ >>> builder.facet(
298
+ ... expensive=lambda products: products.match(price=filter(float).gte(700)),
299
+ ... total=lambda products: products.count(),
300
+ ... )
301
+ """
302
+ branches = _combine(spec, pipelines)
303
+ stage = facet_stage(
304
+ {
305
+ name: _sub_pipeline(branch(self._branch()) if callable(branch) else branch)
306
+ for name, branch in branches.items()
307
+ }
308
+ )
309
+ return self._add(stage, fields=self._replaced(stage["$facet"], with_id=False))
310
+
311
+ def unwind(
312
+ self,
313
+ path: str | Mapping[str, Any],
314
+ *,
315
+ include_array_index: str | None = None,
316
+ preserve_null_and_empty_arrays: bool | None = None,
317
+ ) -> PipelineBuilder[Any]:
318
+ """Adds an ``$unwind`` stage, outputting one document per element of the array ``path``.
319
+
320
+ Example:
321
+ >>> builder.unwind("$tags")
322
+ >>> builder.unwind("$tags", include_array_index="tagIndex")
323
+ """
324
+ stage = unwind_stage(
325
+ path,
326
+ include_array_index=include_array_index,
327
+ preserve_null_and_empty_arrays=preserve_null_and_empty_arrays,
328
+ )
329
+ spec = stage["$unwind"]
330
+ field_path: str = spec if isinstance(spec, str) else spec["path"]
331
+ check_field(field_path[1:], self._fields, "$unwind")
332
+ index_field = None if isinstance(spec, str) else spec.get("includeArrayIndex")
333
+ return self._add(stage, fields=self._extended([index_field] if index_field else []))
334
+
335
+ def replace_root(self, new_root: str | Mapping[str, Any]) -> PipelineBuilder[Any]:
336
+ """Adds a ``$replaceRoot`` stage, replacing each document with ``new_root``.
337
+
338
+ When ``new_root`` is a document literal such as ``{"name": "$name"}``, its keys become
339
+ the known fields. Otherwise the new shape is unknown and field checks stop until
340
+ ``cast()`` is used.
341
+
342
+ Example:
343
+ >>> builder.replace_root("$supplier").cast(Supplier)
344
+ """
345
+ stage = replace_root_stage(new_root)
346
+ root = stage["$replaceRoot"]["newRoot"]
347
+ check_expression(root, self._fields, "$replaceRoot")
348
+ literal = isinstance(root, Mapping) and not any(key.startswith("$") for key in root)
349
+ return self._add(stage, fields=self._replaced(root, with_id=False) if literal else None)
350
+
351
+ def count(self, field: str = DEFAULT_COUNT_FIELD) -> PipelineBuilder[dict[str, int]]:
352
+ """Adds a ``$count`` stage. The result is one document, ``{field: <count>}``."""
353
+ stage = count_stage(field)
354
+ builder = self._add(stage, fields=self._replaced([field], with_id=False))
355
+ return cast("PipelineBuilder[dict[str, int]]", builder)
356
+
357
+ def union_with(self, coll: str, pipeline: SubPipeline | None = None) -> PipelineBuilder[T]:
358
+ """Adds a ``$unionWith`` stage, appending the documents of ``coll``.
359
+
360
+ Example:
361
+ >>> builder.union_with("archived_products", [{"$project": {"name": 1}}])
362
+ """
363
+ stage = union_with_stage(coll, None if pipeline is None else _sub_pipeline(pipeline))
364
+ return self._add(stage)
365
+
366
+ def unset(self, fields: str | Sequence[str]) -> PipelineBuilder[T]:
367
+ """Adds an ``$unset`` stage, removing one or more fields.
368
+
369
+ Example:
370
+ >>> builder.unset(["description", "supplier.region"])
371
+ """
372
+ stage = unset_stage(fields)
373
+ names = [fields] if isinstance(fields, str) else list(fields)
374
+ for name in names:
375
+ check_field(name, self._fields, "$unset")
376
+ return self._add(stage, fields=self._removed(names))
377
+
378
+ def merge(
379
+ self,
380
+ into: str | Mapping[str, str],
381
+ *,
382
+ on: str | Sequence[str] | None = None,
383
+ let: Mapping[str, Any] | None = None,
384
+ when_matched: WhenMatched | None = None,
385
+ when_not_matched: WhenNotMatched | None = None,
386
+ ) -> PipelineBuilder[T]:
387
+ """Adds a ``$merge`` stage, writing the results into ``into``.
388
+
389
+ ``$merge`` must be the last stage: adding another stage after it raises ``ValueError``.
390
+
391
+ Example:
392
+ >>> builder.merge("product_summaries", on="_id", when_matched="replace")
393
+ """
394
+ stage = merge_stage(
395
+ into, on=on, let=let, when_matched=when_matched, when_not_matched=when_not_matched
396
+ )
397
+ for field in [on] if isinstance(on, str) else on or []:
398
+ check_field(field, self._fields, "$merge")
399
+ check_expression(stage["$merge"].get("let"), self._fields, "$merge")
400
+ builder = self._add(stage)
401
+ builder._closed = True
402
+ return builder
403
+
404
+ def graph_lookup(
405
+ self,
406
+ from_: str,
407
+ *,
408
+ start_with: Any,
409
+ connect_from_field: str,
410
+ connect_to_field: str,
411
+ as_: str,
412
+ max_depth: int | None = None,
413
+ depth_field: str | None = None,
414
+ restrict_search_with_match: Mapping[str, Any] | None = None,
415
+ ) -> PipelineBuilder[T]:
416
+ """Adds a ``$graphLookup`` stage, recursively searching ``from_`` into ``as_``.
417
+
418
+ Example:
419
+ >>> builder.graph_lookup(
420
+ ... "employees",
421
+ ... start_with="$managerId",
422
+ ... connect_from_field="managerId",
423
+ ... connect_to_field="_id",
424
+ ... as_="reports",
425
+ ... )
426
+ """
427
+ stage = graph_lookup_stage(
428
+ from_,
429
+ start_with=start_with,
430
+ connect_from_field=connect_from_field,
431
+ connect_to_field=connect_to_field,
432
+ as_=as_,
433
+ max_depth=max_depth,
434
+ depth_field=depth_field,
435
+ restrict_search_with_match=restrict_search_with_match,
436
+ )
437
+ check_expression(stage["$graphLookup"]["startWith"], self._fields, "$graphLookup")
438
+ return self._add(stage, fields=self._extended([as_]))
439
+
440
+ def bucket(
441
+ self,
442
+ group_by: Any,
443
+ boundaries: Sequence[Any],
444
+ *,
445
+ default: Any = _UNSET,
446
+ output: Mapping[str, Any] | None = None,
447
+ ) -> PipelineBuilder[Any]:
448
+ """Adds a ``$bucket`` stage, grouping documents into ranges of ``group_by``.
449
+
450
+ The documents now have ``_id`` (the lower boundary) and the ``output`` fields, or
451
+ ``count`` when there is no ``output``.
452
+
453
+ Example:
454
+ >>> builder.bucket("$price", [0, 300, 1000], default="other")
455
+ """
456
+ stage = bucket_stage(group_by, boundaries, default=default, output=output)
457
+ spec = stage["$bucket"]
458
+ check_expression([spec["groupBy"], spec.get("output")], self._fields, "$bucket")
459
+ return self._add(stage, fields=self._replaced(spec.get("output") or [DEFAULT_COUNT_FIELD]))
460
+
461
+ def bucket_auto(
462
+ self,
463
+ group_by: Any,
464
+ buckets: int,
465
+ *,
466
+ output: Mapping[str, Any] | None = None,
467
+ granularity: Granularity | None = None,
468
+ ) -> PipelineBuilder[Any]:
469
+ """Adds a ``$bucketAuto`` stage, grouping documents into ``buckets`` even buckets.
470
+
471
+ The documents now have ``_id`` (``{"min": ..., "max": ...}``) and the ``output``
472
+ fields, or ``count`` when there is no ``output``.
473
+
474
+ Example:
475
+ >>> builder.bucket_auto("$price", 4)
476
+ """
477
+ stage = bucket_auto_stage(group_by, buckets, output=output, granularity=granularity)
478
+ spec = stage["$bucketAuto"]
479
+ check_expression([spec["groupBy"], spec.get("output")], self._fields, "$bucketAuto")
480
+ return self._add(stage, fields=self._replaced(spec.get("output") or [DEFAULT_COUNT_FIELD]))
481
+
482
+ def redact(self, expression: str | Mapping[str, Any]) -> PipelineBuilder[T]:
483
+ """Adds a ``$redact`` stage.
484
+
485
+ Example:
486
+ >>> active = {"$eq": ["$active", True]}
487
+ >>> builder.redact({"$cond": {"if": active, "then": "$$KEEP", "else": "$$PRUNE"}})
488
+ """
489
+ stage = redact_stage(expression)
490
+ check_expression(stage["$redact"], self._fields, "$redact")
491
+ return self._add(stage)
492
+
493
+ def _add(self, stage: Stage, *, fields: FieldSet = _SAME) -> PipelineBuilder[T]:
494
+ if self._closed:
495
+ raise ValueError("$merge must be the last stage of a pipeline")
496
+ builder = copy.copy(self)
497
+ builder._stages = [*self._stages, stage]
498
+ if fields is not _SAME:
499
+ builder._fields = fields
500
+ return builder
501
+
502
+ def _branch(self) -> PipelineBuilder[T]:
503
+ """Returns an empty builder for the current documents, for ``$facet`` pipelines."""
504
+ builder = copy.copy(self)
505
+ builder._stages = []
506
+ builder._closed = False
507
+ return builder
508
+
509
+ def _extended(self, names: Iterable[str]) -> FieldSet:
510
+ if self._fields is None:
511
+ return None
512
+ return self._fields | {field_root(name) for name in names}
513
+
514
+ def _removed(self, names: Iterable[str]) -> FieldSet:
515
+ if self._fields is None:
516
+ return None
517
+ return self._fields - {name for name in names if "." not in name}
518
+
519
+ def _replaced(self, names: Iterable[str], *, with_id: bool = True) -> FieldSet:
520
+ if self._fields is None:
521
+ return None
522
+ return frozenset(names) | ({ID_FIELD} if with_id else set())
523
+
524
+
525
+ def _sub_pipeline(pipeline: SubPipeline) -> Pipeline:
526
+ if isinstance(pipeline, PipelineBuilder):
527
+ return pipeline.build()
528
+ if isinstance(pipeline, (str, Mapping)):
529
+ raise TypeError(f"Expected a list of stages or a PipelineBuilder, got {pipeline!r}")
530
+ return [dict(stage) for stage in pipeline]
531
+
532
+
533
+ def _combine(spec: Mapping[str, Any] | None, fields: Mapping[str, Any]) -> dict[str, Any]:
534
+ """Merges a stage's mapping argument with its keyword arguments."""
535
+ duplicates = sorted((spec or {}).keys() & fields.keys())
536
+ if duplicates:
537
+ raise TypeError(f"{duplicates} given both in the mapping and as keyword arguments")
538
+ return {**(spec or {}), **fields}
@@ -0,0 +1,5 @@
1
+ ID_FIELD = "_id"
2
+ """MongoDB adds ``_id`` to every document, so it is always a known field."""
3
+
4
+ DEFAULT_COUNT_FIELD = "count"
5
+ """The output field of ``$count``, and of ``$bucket``/``$bucketAuto`` without an ``output``."""
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ from collections.abc import Iterable
5
+
6
+
7
+ class UnknownFieldError(ValueError):
8
+ """Raised when a stage references a field that the current schema does not declare.
9
+
10
+ Attributes:
11
+ field: The unknown top-level field name.
12
+ path: The full path as written, e.g. ``"produt.name"``.
13
+ stage: The stage the field was used in, e.g. ``"$match"``.
14
+ known: The field names the schema does declare.
15
+ """
16
+
17
+ def __init__(self, field: str, stage: str, known: Iterable[str], path: str | None = None):
18
+ self.field = field
19
+ self.path = path or field
20
+ self.stage = stage
21
+ self.known = frozenset(known)
22
+
23
+ known_sorted = sorted(self.known)
24
+ source = f" (from {self.path!r})" if self.path != field else ""
25
+ message = f"Unknown field {field!r}{source} in {stage}."
26
+ suggestions = difflib.get_close_matches(field, known_sorted, n=1)
27
+ if suggestions:
28
+ message += f" Did you mean {suggestions[0]!r}?"
29
+ message += f" Known fields: {', '.join(known_sorted)}."
30
+ super().__init__(message)
File without changes