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.
- mongodb_typesafe_aggregation/__init__.py +59 -0
- mongodb_typesafe_aggregation/builder.py +538 -0
- mongodb_typesafe_aggregation/constants.py +5 -0
- mongodb_typesafe_aggregation/errors.py +30 -0
- mongodb_typesafe_aggregation/py.typed +0 -0
- mongodb_typesafe_aggregation/schema.py +128 -0
- mongodb_typesafe_aggregation/stages/__init__.py +47 -0
- mongodb_typesafe_aggregation/stages/_utils.py +21 -0
- mongodb_typesafe_aggregation/stages/add_fields.py +25 -0
- mongodb_typesafe_aggregation/stages/bucket.py +55 -0
- mongodb_typesafe_aggregation/stages/bucket_auto.py +47 -0
- mongodb_typesafe_aggregation/stages/count.py +25 -0
- mongodb_typesafe_aggregation/stages/facet.py +25 -0
- mongodb_typesafe_aggregation/stages/graph_lookup.py +65 -0
- mongodb_typesafe_aggregation/stages/group.py +29 -0
- mongodb_typesafe_aggregation/stages/limit.py +24 -0
- mongodb_typesafe_aggregation/stages/lookup.py +59 -0
- mongodb_typesafe_aggregation/stages/match.py +24 -0
- mongodb_typesafe_aggregation/stages/merge.py +57 -0
- mongodb_typesafe_aggregation/stages/project.py +55 -0
- mongodb_typesafe_aggregation/stages/redact.py +24 -0
- mongodb_typesafe_aggregation/stages/replace_root.py +24 -0
- mongodb_typesafe_aggregation/stages/set_stage.py +27 -0
- mongodb_typesafe_aggregation/stages/skip.py +24 -0
- mongodb_typesafe_aggregation/stages/sort.py +36 -0
- mongodb_typesafe_aggregation/stages/union_with.py +26 -0
- mongodb_typesafe_aggregation/stages/unset_stage.py +26 -0
- mongodb_typesafe_aggregation/stages/unwind.py +47 -0
- mongodb_typesafe_aggregation/types/__init__.py +11 -0
- mongodb_typesafe_aggregation/types/filter.py +203 -0
- mongodb_typesafe_aggregation-0.1.0.dist-info/METADATA +465 -0
- mongodb_typesafe_aggregation-0.1.0.dist-info/RECORD +34 -0
- mongodb_typesafe_aggregation-0.1.0.dist-info/WHEEL +4 -0
- 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,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
|