qsmongo 0.3.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.
- qsmongo/__init__.py +45 -0
- qsmongo/advisor.py +200 -0
- qsmongo/coerce.py +78 -0
- qsmongo/cursor.py +171 -0
- qsmongo/errors.py +37 -0
- qsmongo/parser.py +279 -0
- qsmongo/query.py +62 -0
- qsmongo/schema.py +116 -0
- qsmongo-0.3.0.dist-info/METADATA +299 -0
- qsmongo-0.3.0.dist-info/RECORD +12 -0
- qsmongo-0.3.0.dist-info/WHEEL +4 -0
- qsmongo-0.3.0.dist-info/licenses/LICENSE +21 -0
qsmongo/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""qsmongo — turn an HTTP query string into a safe MongoDB filter.
|
|
2
|
+
|
|
3
|
+
from qsmongo import Field, Schema, parse
|
|
4
|
+
|
|
5
|
+
schema = Schema(name=Field(str), age=Field(int))
|
|
6
|
+
query = parse("age__gte=21&sort=-age&page=2", schema)
|
|
7
|
+
collection.find(**query.find_kwargs())
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .advisor import Index, IndexAdvice, analyze
|
|
11
|
+
from .coerce import coerce
|
|
12
|
+
from .cursor import Cursors
|
|
13
|
+
from .errors import (
|
|
14
|
+
InvalidCursor,
|
|
15
|
+
InvalidPagination,
|
|
16
|
+
InvalidProjection,
|
|
17
|
+
InvalidValue,
|
|
18
|
+
QSMongoError,
|
|
19
|
+
UnknownField,
|
|
20
|
+
UnsupportedOperator,
|
|
21
|
+
)
|
|
22
|
+
from .parser import parse
|
|
23
|
+
from .query import Query
|
|
24
|
+
from .schema import Field, Schema
|
|
25
|
+
|
|
26
|
+
__version__ = "0.3.0"
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Cursors",
|
|
30
|
+
"Field",
|
|
31
|
+
"Index",
|
|
32
|
+
"IndexAdvice",
|
|
33
|
+
"InvalidCursor",
|
|
34
|
+
"InvalidPagination",
|
|
35
|
+
"InvalidProjection",
|
|
36
|
+
"InvalidValue",
|
|
37
|
+
"QSMongoError",
|
|
38
|
+
"Query",
|
|
39
|
+
"Schema",
|
|
40
|
+
"UnknownField",
|
|
41
|
+
"UnsupportedOperator",
|
|
42
|
+
"analyze",
|
|
43
|
+
"coerce",
|
|
44
|
+
"parse",
|
|
45
|
+
]
|
qsmongo/advisor.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Index advice for a parsed query.
|
|
2
|
+
|
|
3
|
+
This is a **lint, not a query planner**. It applies MongoDB's ESR guideline — an efficient compound
|
|
4
|
+
index lists Equality fields first, then Sort fields, then Range fields — to the query qsmongo just
|
|
5
|
+
built, and tells you whether one of your declared indexes can serve it. `explain()` remains the
|
|
6
|
+
only ground truth; this is here to catch the obvious problems before they reach a database.
|
|
7
|
+
|
|
8
|
+
from qsmongo import Index, analyze, parse
|
|
9
|
+
|
|
10
|
+
query = parse(request.url.query, schema)
|
|
11
|
+
advice = analyze(query, INDEXES, extra_equality=["tenant_id"])
|
|
12
|
+
if not advice.ok:
|
|
13
|
+
log.warning("unindexed query: %s", advice)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .query import Query
|
|
22
|
+
|
|
23
|
+
RANGE_OPERATORS = frozenset({"$gt", "$gte", "$lt", "$lte"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, init=False)
|
|
27
|
+
class Index:
|
|
28
|
+
"""A compound index, in the same key order MongoDB stores it."""
|
|
29
|
+
|
|
30
|
+
keys: tuple[tuple[str, int], ...]
|
|
31
|
+
name: str | None = None
|
|
32
|
+
|
|
33
|
+
def __init__(self, keys, name: str | None = None):
|
|
34
|
+
object.__setattr__(self, "keys", tuple((column, int(direction)) for column, direction in keys))
|
|
35
|
+
object.__setattr__(self, "name", name)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_index_information(cls, information: dict[str, Any]) -> list[Index]:
|
|
39
|
+
"""Build the index list straight from ``collection.index_information()``."""
|
|
40
|
+
indexes = []
|
|
41
|
+
for name, spec in information.items():
|
|
42
|
+
keys = [(column, direction) for column, direction in spec["key"] if isinstance(direction, int)]
|
|
43
|
+
if keys:
|
|
44
|
+
indexes.append(cls(keys, name=name))
|
|
45
|
+
return indexes
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def columns(self) -> tuple[str, ...]:
|
|
49
|
+
return tuple(column for column, _ in self.keys)
|
|
50
|
+
|
|
51
|
+
def __str__(self) -> str:
|
|
52
|
+
body = ", ".join(f"{column}: {direction}" for column, direction in self.keys)
|
|
53
|
+
return f"{{{body}}}" + (f" ({self.name})" if self.name else "")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class IndexAdvice:
|
|
58
|
+
"""What the lint concluded."""
|
|
59
|
+
|
|
60
|
+
ok: bool
|
|
61
|
+
index: Index | None = None
|
|
62
|
+
covered: bool = False
|
|
63
|
+
warnings: tuple[str, ...] = ()
|
|
64
|
+
suggestion: tuple[tuple[str, int], ...] = ()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def suggestion_spec(self) -> dict[str, int]:
|
|
68
|
+
"""The suggested index in ``create_index`` form."""
|
|
69
|
+
return dict(self.suggestion)
|
|
70
|
+
|
|
71
|
+
def __str__(self) -> str:
|
|
72
|
+
if self.ok:
|
|
73
|
+
head = f"served by {self.index}" + (" (covered)" if self.covered else "")
|
|
74
|
+
else:
|
|
75
|
+
head = "no index serves this query"
|
|
76
|
+
lines = [head, *(f" - {warning}" for warning in self.warnings)]
|
|
77
|
+
if not self.ok and self.suggestion:
|
|
78
|
+
lines.append(f" suggested index: {Index(self.suggestion)}")
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _classify(filter_: dict[str, Any], equality: dict[str, None], ranges: dict[str, None], warnings: list[str]):
|
|
83
|
+
"""Sort every predicate into equality, range, or 'cannot use an index'."""
|
|
84
|
+
for key, value in filter_.items():
|
|
85
|
+
if key == "$and":
|
|
86
|
+
for branch in value:
|
|
87
|
+
_classify(branch, equality, ranges, warnings)
|
|
88
|
+
elif key == "$or":
|
|
89
|
+
# The keyset clause lands here. Its fields are the sort keys, and it reads as a seek
|
|
90
|
+
# into the index, so treat them as ranges rather than warning about an index union.
|
|
91
|
+
for branch in value:
|
|
92
|
+
seen: dict[str, None] = {}
|
|
93
|
+
_classify(branch, seen, seen, warnings)
|
|
94
|
+
for column in seen:
|
|
95
|
+
ranges.setdefault(column, None)
|
|
96
|
+
elif isinstance(value, dict):
|
|
97
|
+
operators = set(value)
|
|
98
|
+
if "$in" in operators:
|
|
99
|
+
equality.setdefault(key, None)
|
|
100
|
+
if operators & RANGE_OPERATORS:
|
|
101
|
+
ranges.setdefault(key, None)
|
|
102
|
+
if "$regex" in operators:
|
|
103
|
+
anchored = str(value["$regex"]).startswith("^") and "i" not in str(value.get("$options", ""))
|
|
104
|
+
if anchored:
|
|
105
|
+
ranges.setdefault(key, None)
|
|
106
|
+
else:
|
|
107
|
+
warnings.append(
|
|
108
|
+
f"{key}: an unanchored or case-insensitive regex cannot use an index and scans "
|
|
109
|
+
"every candidate document"
|
|
110
|
+
)
|
|
111
|
+
if "$ne" in operators or "$nin" in operators:
|
|
112
|
+
warnings.append(f"{key}: $ne/$nin match almost everything, so an index barely narrows the scan")
|
|
113
|
+
if value.get("$exists") is False:
|
|
114
|
+
warnings.append(f"{key}: $exists=false cannot use a standard index")
|
|
115
|
+
else:
|
|
116
|
+
equality.setdefault(key, None)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _match(index: Index, equality: dict[str, None], sort: list[tuple[str, int]], ranges: dict[str, None]):
|
|
120
|
+
"""Walk the index keys ESR-style. Returns (prefix_length, sort_is_indexed)."""
|
|
121
|
+
keys = index.keys
|
|
122
|
+
position = 0
|
|
123
|
+
while position < len(keys) and keys[position][0] in equality:
|
|
124
|
+
position += 1
|
|
125
|
+
|
|
126
|
+
# A sort on a field pinned by an equality predicate is free — every matching document shares
|
|
127
|
+
# that value — so it does not need to appear in the index after the prefix.
|
|
128
|
+
effective = [entry for entry in sort if entry[0] not in equality]
|
|
129
|
+
if not effective:
|
|
130
|
+
return position, True
|
|
131
|
+
|
|
132
|
+
window = keys[position : position + len(effective)]
|
|
133
|
+
if len(window) < len(effective):
|
|
134
|
+
return position, False
|
|
135
|
+
same = all(w == e for w, e in zip(window, effective, strict=True))
|
|
136
|
+
# Walking an index backwards is free, but only if every key is reversed.
|
|
137
|
+
reversed_ = all(w[0] == e[0] and w[1] == -e[1] for w, e in zip(window, effective, strict=True))
|
|
138
|
+
return position, same or reversed_
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def analyze(
|
|
142
|
+
query: Query,
|
|
143
|
+
indexes: list[Index] | None = None,
|
|
144
|
+
*,
|
|
145
|
+
extra_equality: list[str] = (),
|
|
146
|
+
) -> IndexAdvice:
|
|
147
|
+
"""Check ``query`` against ``indexes`` and suggest one when nothing fits.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
indexes: your declared indexes, or ``Index.from_index_information(collection.index_information())``.
|
|
151
|
+
extra_equality: columns your own code pins outside the parsed filter — a tenant id, a
|
|
152
|
+
soft-delete flag. They belong at the front of the index, so the advice is wrong
|
|
153
|
+
without them.
|
|
154
|
+
"""
|
|
155
|
+
equality: dict[str, None] = {column: None for column in extra_equality}
|
|
156
|
+
ranges: dict[str, None] = {}
|
|
157
|
+
warnings: list[str] = []
|
|
158
|
+
_classify(query.filter, equality, ranges, warnings)
|
|
159
|
+
for column in ranges: # a field with both bounds is a range, not an equality
|
|
160
|
+
equality.pop(column, None)
|
|
161
|
+
|
|
162
|
+
sort = list(query.sort)
|
|
163
|
+
effective_sort = [entry for entry in sort if entry[0] not in equality]
|
|
164
|
+
suggestion = tuple(
|
|
165
|
+
[(column, 1) for column in equality]
|
|
166
|
+
+ effective_sort
|
|
167
|
+
+ [(column, 1) for column in ranges if column not in dict(effective_sort)]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
best: tuple[int, bool] = (0, False)
|
|
171
|
+
best_index: Index | None = None
|
|
172
|
+
for index in indexes or []:
|
|
173
|
+
prefix, sort_ok = _match(index, equality, sort, ranges)
|
|
174
|
+
usable = prefix > 0 or (index.keys and index.keys[0][0] in ranges)
|
|
175
|
+
if not usable:
|
|
176
|
+
continue
|
|
177
|
+
if (sort_ok, prefix) > (best[1], best[0]):
|
|
178
|
+
best, best_index = (prefix, sort_ok), index
|
|
179
|
+
|
|
180
|
+
ok = best_index is not None and best[1]
|
|
181
|
+
if best_index is not None and not best[1]:
|
|
182
|
+
warnings.append(
|
|
183
|
+
f"{best_index} filters this query but does not provide the sort {Index(effective_sort)}, "
|
|
184
|
+
"so MongoDB sorts in memory (it aborts past its blocking-sort memory limit, 100 MB by default)"
|
|
185
|
+
)
|
|
186
|
+
elif best_index is None and (equality or ranges or effective_sort):
|
|
187
|
+
warnings.append("no index matches the leading field of this query, so it is a collection scan")
|
|
188
|
+
|
|
189
|
+
covered = False
|
|
190
|
+
if ok and query.projection and all(direction == 1 for direction in query.projection.values()):
|
|
191
|
+
wanted = set(query.projection) - {"_id"}
|
|
192
|
+
covered = wanted.issubset(set(best_index.columns))
|
|
193
|
+
|
|
194
|
+
return IndexAdvice(
|
|
195
|
+
ok=ok,
|
|
196
|
+
index=best_index,
|
|
197
|
+
covered=covered,
|
|
198
|
+
warnings=tuple(warnings),
|
|
199
|
+
suggestion=() if ok else suggestion,
|
|
200
|
+
)
|
qsmongo/coerce.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""String -> typed value conversion.
|
|
2
|
+
|
|
3
|
+
Everything arriving from a URL is a string. Mongo, however, compares by type: a document with
|
|
4
|
+
``{"age": 21}`` will never match ``{"age": "21"}``. Silent type mismatch is the single most common
|
|
5
|
+
cause of "the filter returns nothing and no one knows why", so coercion is strict and loud.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
|
|
12
|
+
from .errors import InvalidValue
|
|
13
|
+
|
|
14
|
+
try: # pragma: no cover
|
|
15
|
+
from bson import ObjectId
|
|
16
|
+
except ImportError: # pragma: no cover
|
|
17
|
+
ObjectId = None
|
|
18
|
+
|
|
19
|
+
TRUE_VALUES = frozenset({"true", "1", "yes", "on"})
|
|
20
|
+
FALSE_VALUES = frozenset({"false", "0", "no", "off"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _to_bool(raw: str, param: str) -> bool:
|
|
24
|
+
lowered = raw.strip().lower()
|
|
25
|
+
if lowered in TRUE_VALUES:
|
|
26
|
+
return True
|
|
27
|
+
if lowered in FALSE_VALUES:
|
|
28
|
+
return False
|
|
29
|
+
raise InvalidValue(f"{param}: expected a boolean, got {raw!r}", param=param)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _to_datetime(raw: str, param: str) -> datetime:
|
|
33
|
+
text = raw.strip()
|
|
34
|
+
# datetime.fromisoformat only learned to accept a trailing "Z" in 3.11; normalise for 3.10.
|
|
35
|
+
if text.endswith(("Z", "z")):
|
|
36
|
+
text = f"{text[:-1]}+00:00"
|
|
37
|
+
try:
|
|
38
|
+
return datetime.fromisoformat(text)
|
|
39
|
+
except ValueError:
|
|
40
|
+
raise InvalidValue(f"{param}: expected an ISO-8601 datetime, got {raw!r}", param=param) from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _to_date(raw: str, param: str) -> datetime:
|
|
44
|
+
try:
|
|
45
|
+
parsed = date.fromisoformat(raw.strip())
|
|
46
|
+
except ValueError:
|
|
47
|
+
raise InvalidValue(f"{param}: expected an ISO-8601 date, got {raw!r}", param=param) from None
|
|
48
|
+
# Mongo has no date type: store dates as midnight datetimes so comparisons behave.
|
|
49
|
+
return datetime(parsed.year, parsed.month, parsed.day)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_object_id(raw: str, param: str):
|
|
53
|
+
if ObjectId is None: # pragma: no cover
|
|
54
|
+
raise InvalidValue(f"{param}: ObjectId fields require pymongo to be installed", param=param)
|
|
55
|
+
try:
|
|
56
|
+
return ObjectId(raw.strip())
|
|
57
|
+
except Exception:
|
|
58
|
+
raise InvalidValue(f"{param}: expected a 24-character ObjectId, got {raw!r}", param=param) from None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def coerce(type_: type, raw: str, param: str):
|
|
62
|
+
"""Convert one raw query-string value to the field's declared type."""
|
|
63
|
+
if type_ is str:
|
|
64
|
+
return raw
|
|
65
|
+
if type_ is bool:
|
|
66
|
+
return _to_bool(raw, param)
|
|
67
|
+
if type_ is datetime:
|
|
68
|
+
return _to_datetime(raw, param)
|
|
69
|
+
if type_ is date:
|
|
70
|
+
return _to_date(raw, param)
|
|
71
|
+
if ObjectId is not None and type_ is ObjectId:
|
|
72
|
+
return _to_object_id(raw, param)
|
|
73
|
+
if type_ in (int, float):
|
|
74
|
+
try:
|
|
75
|
+
return type_(raw.strip())
|
|
76
|
+
except ValueError:
|
|
77
|
+
raise InvalidValue(f"{param}: expected {type_.__name__}, got {raw!r}", param=param) from None
|
|
78
|
+
raise InvalidValue(f"{param}: no coercion registered for {type_.__name__}", param=param)
|
qsmongo/cursor.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Keyset (cursor) pagination.
|
|
2
|
+
|
|
3
|
+
``skip``/``limit`` degrades linearly: to serve page 2000 the server walks 100,000 documents it will
|
|
4
|
+
never return. Keyset pagination replaces the skip with a range clause built from the last document
|
|
5
|
+
of the previous page, so every page costs the same as the first — provided the sort is backed by an
|
|
6
|
+
index.
|
|
7
|
+
|
|
8
|
+
The cursor is opaque to clients and, when a secret is supplied, HMAC-signed so it cannot be forged
|
|
9
|
+
into a filter of the client's choosing.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import json
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .errors import InvalidCursor
|
|
22
|
+
|
|
23
|
+
try: # pragma: no cover
|
|
24
|
+
from bson import ObjectId
|
|
25
|
+
except ImportError: # pragma: no cover
|
|
26
|
+
ObjectId = None
|
|
27
|
+
|
|
28
|
+
_MISSING = object()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _b64encode(raw: bytes) -> str:
|
|
32
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _b64decode(text: str) -> bytes:
|
|
36
|
+
padding = "=" * (-len(text) % 4)
|
|
37
|
+
try:
|
|
38
|
+
return base64.urlsafe_b64decode(text + padding)
|
|
39
|
+
except Exception:
|
|
40
|
+
raise InvalidCursor("cursor is not valid base64", param="after") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _pack(value: Any) -> list[Any]:
|
|
44
|
+
"""Tag a value with its type so decoding does not need the schema."""
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
return ["b", value]
|
|
47
|
+
if isinstance(value, datetime):
|
|
48
|
+
return ["d", value.isoformat()]
|
|
49
|
+
if ObjectId is not None and isinstance(value, ObjectId):
|
|
50
|
+
return ["o", str(value)]
|
|
51
|
+
if isinstance(value, (int, float, str)) or value is None:
|
|
52
|
+
return ["j", value]
|
|
53
|
+
raise InvalidCursor(f"cannot put {type(value).__name__} in a cursor", param="after")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unpack(item: Any) -> Any:
|
|
57
|
+
if not isinstance(item, list) or len(item) != 2:
|
|
58
|
+
raise InvalidCursor("malformed cursor value", param="after")
|
|
59
|
+
tag, value = item
|
|
60
|
+
if tag == "j" or tag == "b":
|
|
61
|
+
return value
|
|
62
|
+
if tag == "d":
|
|
63
|
+
try:
|
|
64
|
+
return datetime.fromisoformat(value)
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
raise InvalidCursor("malformed datetime in cursor", param="after") from None
|
|
67
|
+
if tag == "o":
|
|
68
|
+
if ObjectId is None: # pragma: no cover
|
|
69
|
+
raise InvalidCursor("cursor contains an ObjectId but pymongo is not installed", param="after")
|
|
70
|
+
try:
|
|
71
|
+
return ObjectId(value)
|
|
72
|
+
except Exception:
|
|
73
|
+
raise InvalidCursor("malformed ObjectId in cursor", param="after") from None
|
|
74
|
+
raise InvalidCursor(f"unknown cursor value type {tag!r}", param="after")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _dig(document: dict, path: str) -> Any:
|
|
78
|
+
"""Read a possibly dotted path out of a document."""
|
|
79
|
+
current: Any = document
|
|
80
|
+
for part in path.split("."):
|
|
81
|
+
if not isinstance(current, dict) or part not in current:
|
|
82
|
+
return _MISSING
|
|
83
|
+
current = current[part]
|
|
84
|
+
return current
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Cursors:
|
|
88
|
+
"""Encodes and decodes pagination cursors.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
secret: signing key. Strongly recommended — without it a client can hand-craft a cursor
|
|
92
|
+
and page from an arbitrary position. Signing does not hide the contents; it only
|
|
93
|
+
proves the server issued them.
|
|
94
|
+
id_field: the tiebreaker field appended to every sort so the order is total. Two documents
|
|
95
|
+
sharing a ``created_at`` would otherwise be able to straddle a page boundary, and one
|
|
96
|
+
of them would be skipped or repeated.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
__slots__ = ("_secret", "id_field")
|
|
100
|
+
|
|
101
|
+
def __init__(self, secret: bytes | str | None = None, *, id_field: str = "_id"):
|
|
102
|
+
if isinstance(secret, str):
|
|
103
|
+
secret = secret.encode()
|
|
104
|
+
self._secret = secret
|
|
105
|
+
self.id_field = id_field
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def signed(self) -> bool:
|
|
109
|
+
return self._secret is not None
|
|
110
|
+
|
|
111
|
+
def _signature(self, payload: bytes) -> str:
|
|
112
|
+
return _b64encode(hmac.new(self._secret, payload, hashlib.sha256).digest())
|
|
113
|
+
|
|
114
|
+
def encode(self, document: dict, sort: list[tuple[str, int]]) -> str:
|
|
115
|
+
"""Build the cursor that resumes *after* ``document``, given the sort used to fetch it."""
|
|
116
|
+
values = []
|
|
117
|
+
for column, _ in sort:
|
|
118
|
+
value = _dig(document, column)
|
|
119
|
+
if value is _MISSING:
|
|
120
|
+
raise InvalidCursor(f"document has no {column!r} to build a cursor from", param="after")
|
|
121
|
+
values.append(_pack(value))
|
|
122
|
+
payload = json.dumps(values, separators=(",", ":"), sort_keys=True).encode()
|
|
123
|
+
token = _b64encode(payload)
|
|
124
|
+
if self._secret is None:
|
|
125
|
+
return token
|
|
126
|
+
return f"{token}.{self._signature(payload)}"
|
|
127
|
+
|
|
128
|
+
def decode(self, token: str) -> list[Any]:
|
|
129
|
+
signature = None
|
|
130
|
+
if "." in token:
|
|
131
|
+
token, _, signature = token.partition(".")
|
|
132
|
+
if self._secret is None:
|
|
133
|
+
if signature is not None:
|
|
134
|
+
raise InvalidCursor("cursor is signed but no secret is configured", param="after")
|
|
135
|
+
else:
|
|
136
|
+
if signature is None:
|
|
137
|
+
raise InvalidCursor("cursor is not signed", param="after")
|
|
138
|
+
if not hmac.compare_digest(self._signature(_b64decode(token)), signature):
|
|
139
|
+
raise InvalidCursor("cursor signature does not match", param="after")
|
|
140
|
+
try:
|
|
141
|
+
values = json.loads(_b64decode(token))
|
|
142
|
+
except ValueError:
|
|
143
|
+
raise InvalidCursor("cursor is not valid JSON", param="after") from None
|
|
144
|
+
if not isinstance(values, list):
|
|
145
|
+
raise InvalidCursor("cursor payload must be a list", param="after")
|
|
146
|
+
return [_unpack(item) for item in values]
|
|
147
|
+
|
|
148
|
+
def __repr__(self) -> str: # never leak the secret into a log line
|
|
149
|
+
return f"Cursors(signed={self.signed}, id_field={self.id_field!r})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def keyset_filter(sort: list[tuple[str, int]], values: list[Any]) -> dict[str, Any]:
|
|
153
|
+
"""Range clause selecting everything strictly after ``values`` in ``sort`` order.
|
|
154
|
+
|
|
155
|
+
For ``sort=[("created_at", -1), ("_id", 1)]`` this produces the lexicographic comparison::
|
|
156
|
+
|
|
157
|
+
{"$or": [{"created_at": {"$lt": t}},
|
|
158
|
+
{"created_at": t, "_id": {"$gt": i}}]}
|
|
159
|
+
"""
|
|
160
|
+
if len(values) != len(sort):
|
|
161
|
+
raise InvalidCursor(
|
|
162
|
+
f"cursor has {len(values)} value(s) but the sort has {len(sort)} field(s); "
|
|
163
|
+
"the sort must match the one that produced the cursor",
|
|
164
|
+
param="after",
|
|
165
|
+
)
|
|
166
|
+
branches: list[dict[str, Any]] = []
|
|
167
|
+
for index, (column, direction) in enumerate(sort):
|
|
168
|
+
clause: dict[str, Any] = {sort[j][0]: values[j] for j in range(index)}
|
|
169
|
+
clause[column] = {"$gt" if direction == 1 else "$lt": values[index]}
|
|
170
|
+
branches.append(clause)
|
|
171
|
+
return branches[0] if len(branches) == 1 else {"$or": branches}
|
qsmongo/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions raised while parsing a query string.
|
|
2
|
+
|
|
3
|
+
Every error carries the offending parameter so an API layer can turn it into a useful 400
|
|
4
|
+
response instead of a generic "bad request".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class QSMongoError(ValueError):
|
|
9
|
+
"""Base class for every parse failure."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, param: str | None = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.param = param
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnknownField(QSMongoError):
|
|
17
|
+
"""The query string referenced a field that is not declared in the schema."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedOperator(QSMongoError):
|
|
21
|
+
"""The field exists but does not allow this operator."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidValue(QSMongoError):
|
|
25
|
+
"""A value could not be coerced to the field's declared type, or conflicts with another clause."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidPagination(QSMongoError):
|
|
29
|
+
"""page / per_page was not a usable positive integer, or two paging modes were mixed."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidCursor(QSMongoError):
|
|
33
|
+
"""A keyset cursor was malformed, unsigned, forged, or does not match the requested sort."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidProjection(QSMongoError):
|
|
37
|
+
"""The requested field selection cannot be turned into a MongoDB projection."""
|
qsmongo/parser.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Query string -> :class:`~qsmongo.query.Query`.
|
|
2
|
+
|
|
3
|
+
Grammar::
|
|
4
|
+
|
|
5
|
+
field=value equality
|
|
6
|
+
field__<op>=value ne gt gte lt lte in nin exists contains startswith endswith regex
|
|
7
|
+
field__in=a,b,c comma separated list, "\\," escapes a literal comma
|
|
8
|
+
fields=name,price projection (or fields=-secret to exclude)
|
|
9
|
+
sort=-created_at,name "-" prefix means descending
|
|
10
|
+
page=2&per_page=50 offset paging, clamped to max_per_page
|
|
11
|
+
after=<cursor>&per_page=50 keyset paging, mutually exclusive with page
|
|
12
|
+
|
|
13
|
+
Field names are matched against a :class:`~qsmongo.schema.Schema`; anything undeclared raises.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any
|
|
20
|
+
from urllib.parse import parse_qsl
|
|
21
|
+
|
|
22
|
+
from .coerce import coerce
|
|
23
|
+
from .cursor import Cursors, keyset_filter
|
|
24
|
+
from .errors import (
|
|
25
|
+
InvalidCursor,
|
|
26
|
+
InvalidPagination,
|
|
27
|
+
InvalidProjection,
|
|
28
|
+
InvalidValue,
|
|
29
|
+
UnsupportedOperator,
|
|
30
|
+
)
|
|
31
|
+
from .query import Query
|
|
32
|
+
from .schema import Schema
|
|
33
|
+
|
|
34
|
+
OP_SUFFIXES = {
|
|
35
|
+
"ne": "$ne",
|
|
36
|
+
"gt": "$gt",
|
|
37
|
+
"gte": "$gte",
|
|
38
|
+
"lt": "$lt",
|
|
39
|
+
"lte": "$lte",
|
|
40
|
+
"in": "$in",
|
|
41
|
+
"nin": "$nin",
|
|
42
|
+
"regex": "$regex",
|
|
43
|
+
"exists": "$exists",
|
|
44
|
+
"contains": "$regex",
|
|
45
|
+
"startswith": "$regex",
|
|
46
|
+
"endswith": "$regex",
|
|
47
|
+
}
|
|
48
|
+
LIST_OPS = frozenset({"in", "nin"})
|
|
49
|
+
MAX_REGEX_LENGTH = 200
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _split_list(raw: str) -> list[str]:
|
|
53
|
+
"""Split on commas, honouring a backslash escape so values may contain a literal comma."""
|
|
54
|
+
parts: list[str] = []
|
|
55
|
+
current: list[str] = []
|
|
56
|
+
escaped = False
|
|
57
|
+
for char in raw:
|
|
58
|
+
if escaped:
|
|
59
|
+
current.append(char)
|
|
60
|
+
escaped = False
|
|
61
|
+
elif char == "\\":
|
|
62
|
+
escaped = True
|
|
63
|
+
elif char == ",":
|
|
64
|
+
parts.append("".join(current))
|
|
65
|
+
current = []
|
|
66
|
+
else:
|
|
67
|
+
current.append(char)
|
|
68
|
+
if escaped: # trailing backslash is a literal backslash
|
|
69
|
+
current.append("\\")
|
|
70
|
+
parts.append("".join(current))
|
|
71
|
+
return [p for p in parts if p != ""]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _split_key(key: str) -> tuple[str, str]:
|
|
75
|
+
"""``"age__gte"`` -> ``("age", "gte")``. A field with no known suffix is an equality match."""
|
|
76
|
+
if "__" in key:
|
|
77
|
+
field, _, suffix = key.rpartition("__")
|
|
78
|
+
if field and suffix in OP_SUFFIXES:
|
|
79
|
+
return field, suffix
|
|
80
|
+
return key, "eq"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _positive_int(raw: str, param: str) -> int:
|
|
84
|
+
try:
|
|
85
|
+
value = int(raw.strip())
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise InvalidPagination(f"{param}: expected a positive integer, got {raw!r}", param=param) from None
|
|
88
|
+
if value < 1:
|
|
89
|
+
raise InvalidPagination(f"{param}: must be 1 or greater, got {value}", param=param)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_sort(raw: str, schema: Schema) -> list[tuple[str, int]]:
|
|
94
|
+
order: list[tuple[str, int]] = []
|
|
95
|
+
for token in raw.split(","):
|
|
96
|
+
token = token.strip()
|
|
97
|
+
if not token:
|
|
98
|
+
continue
|
|
99
|
+
direction = 1
|
|
100
|
+
if token.startswith("-"):
|
|
101
|
+
direction = -1
|
|
102
|
+
token = token[1:]
|
|
103
|
+
field_def = schema.get(token) # raises UnknownField
|
|
104
|
+
if not field_def.sortable:
|
|
105
|
+
raise UnsupportedOperator(f"field {token!r} is not sortable", param=token)
|
|
106
|
+
order.append((schema.column(token), direction))
|
|
107
|
+
return order
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_projection(raw: str, schema: Schema) -> dict[str, int]:
|
|
111
|
+
"""``"name,price"`` -> ``{"name": 1, "price": 1}``; ``"-secret"`` -> ``{"secret": 0}``."""
|
|
112
|
+
include: dict[str, int] = {}
|
|
113
|
+
exclude: dict[str, int] = {}
|
|
114
|
+
for token in raw.split(","):
|
|
115
|
+
token = token.strip()
|
|
116
|
+
if not token:
|
|
117
|
+
continue
|
|
118
|
+
excluded = token.startswith("-")
|
|
119
|
+
if excluded:
|
|
120
|
+
token = token[1:]
|
|
121
|
+
field_def = schema.get(token) # raises UnknownField
|
|
122
|
+
if not field_def.projectable:
|
|
123
|
+
raise UnsupportedOperator(f"field {token!r} cannot be selected", param=token)
|
|
124
|
+
(exclude if excluded else include)[schema.column(token)] = 0 if excluded else 1
|
|
125
|
+
if include and exclude:
|
|
126
|
+
# Mongo rejects mixed projections (only _id may be excluded alongside inclusions), so
|
|
127
|
+
# catching it here gives a 400 instead of an OperationFailure from the driver.
|
|
128
|
+
raise InvalidProjection("fields cannot mix included and excluded names; use one or the other", param="fields")
|
|
129
|
+
return include or exclude
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _text_clause(field_def, op: str, raw: str, param: str) -> dict[str, Any]:
|
|
133
|
+
"""contains/startswith/endswith as an escaped pattern — user input is never a pattern."""
|
|
134
|
+
if not raw:
|
|
135
|
+
raise InvalidValue(f"{param}: expected a non-empty value", param=param)
|
|
136
|
+
escaped = re.escape(raw)
|
|
137
|
+
pattern = {"contains": escaped, "startswith": f"^{escaped}", "endswith": f"{escaped}$"}[op]
|
|
138
|
+
clause: dict[str, Any] = {"$regex": pattern}
|
|
139
|
+
if not field_def.case_sensitive:
|
|
140
|
+
clause["$options"] = "i"
|
|
141
|
+
return clause
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_clause(field_def, op: str, raw: str, param: str) -> dict[str, Any]:
|
|
145
|
+
"""Everything except equality, as the operator document Mongo expects."""
|
|
146
|
+
if op == "exists":
|
|
147
|
+
return {"$exists": coerce(bool, raw, param)}
|
|
148
|
+
if op in LIST_OPS:
|
|
149
|
+
values = [coerce(field_def.type, item, param) for item in _split_list(raw)]
|
|
150
|
+
if not values:
|
|
151
|
+
raise InvalidValue(f"{param}: expected at least one value", param=param)
|
|
152
|
+
return {OP_SUFFIXES[op]: values}
|
|
153
|
+
if op in ("contains", "startswith", "endswith"):
|
|
154
|
+
return _text_clause(field_def, op, raw, param)
|
|
155
|
+
if op == "regex":
|
|
156
|
+
if len(raw) > MAX_REGEX_LENGTH:
|
|
157
|
+
raise InvalidValue(f"{param}: pattern exceeds {MAX_REGEX_LENGTH} characters", param=param)
|
|
158
|
+
return {"$regex": raw}
|
|
159
|
+
return {OP_SUFFIXES[op]: coerce(field_def.type, raw, param)}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse(
|
|
163
|
+
query_string: str,
|
|
164
|
+
schema: Schema,
|
|
165
|
+
*,
|
|
166
|
+
default_per_page: int = 25,
|
|
167
|
+
max_per_page: int = 100,
|
|
168
|
+
cursors: Cursors | None = None,
|
|
169
|
+
page_param: str = "page",
|
|
170
|
+
per_page_param: str = "per_page",
|
|
171
|
+
sort_param: str = "sort",
|
|
172
|
+
fields_param: str = "fields",
|
|
173
|
+
after_param: str = "after",
|
|
174
|
+
ignore_unknown: bool = False,
|
|
175
|
+
) -> Query:
|
|
176
|
+
"""Parse ``query_string`` into a :class:`Query` validated against ``schema``.
|
|
177
|
+
|
|
178
|
+
Pass ``cursors`` to enable keyset pagination via ``?after=``; without it the parameter is
|
|
179
|
+
rejected. Set ``ignore_unknown`` to skip undeclared parameters instead of raising — useful when
|
|
180
|
+
the same URL carries parameters your view consumes (``?include=...``, cache busters).
|
|
181
|
+
"""
|
|
182
|
+
pairs = parse_qsl(query_string.lstrip("?"), keep_blank_values=True)
|
|
183
|
+
|
|
184
|
+
equality: dict[str, tuple[str, list[Any]]] = {}
|
|
185
|
+
operators: dict[str, dict[str, Any]] = {}
|
|
186
|
+
sort: list[tuple[str, int]] = []
|
|
187
|
+
projection: dict[str, int] = {}
|
|
188
|
+
page: int | None = None
|
|
189
|
+
per_page = default_per_page
|
|
190
|
+
after: str | None = None
|
|
191
|
+
|
|
192
|
+
for raw_key, raw_value in pairs:
|
|
193
|
+
key = raw_key.strip()
|
|
194
|
+
if key == page_param:
|
|
195
|
+
page = _positive_int(raw_value, page_param)
|
|
196
|
+
continue
|
|
197
|
+
if key == per_page_param:
|
|
198
|
+
per_page = min(_positive_int(raw_value, per_page_param), max_per_page)
|
|
199
|
+
continue
|
|
200
|
+
if key == sort_param:
|
|
201
|
+
sort.extend(_parse_sort(raw_value, schema))
|
|
202
|
+
continue
|
|
203
|
+
if key == fields_param:
|
|
204
|
+
projection.update(_parse_projection(raw_value, schema))
|
|
205
|
+
continue
|
|
206
|
+
if key == after_param:
|
|
207
|
+
after = raw_value
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
field_name, op = _split_key(key)
|
|
211
|
+
if ignore_unknown and field_name not in schema:
|
|
212
|
+
continue
|
|
213
|
+
field_def = schema.get(field_name) # raises UnknownField
|
|
214
|
+
if op not in field_def.ops:
|
|
215
|
+
raise UnsupportedOperator(
|
|
216
|
+
f"field {field_name!r} does not support {op!r} (allowed: {sorted(field_def.ops)})", param=key
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
column = schema.column(field_name)
|
|
220
|
+
if op == "eq":
|
|
221
|
+
equality.setdefault(column, (field_name, []))[1].append(coerce(field_def.type, raw_value, key))
|
|
222
|
+
continue
|
|
223
|
+
clause = _build_clause(field_def, op, raw_value, key)
|
|
224
|
+
existing = operators.setdefault(column, {})
|
|
225
|
+
duplicate = set(existing) & set(clause) - {"$options"}
|
|
226
|
+
if duplicate:
|
|
227
|
+
raise InvalidValue(
|
|
228
|
+
f"{field_name!r} was given two {sorted(duplicate)[0]} clauses; combine them into one", param=key
|
|
229
|
+
)
|
|
230
|
+
existing.update(clause)
|
|
231
|
+
|
|
232
|
+
filter_: dict[str, Any] = {}
|
|
233
|
+
for column, (field_name, values) in equality.items():
|
|
234
|
+
if len(values) == 1:
|
|
235
|
+
filter_[column] = values[0]
|
|
236
|
+
else:
|
|
237
|
+
if not schema.get(field_name).multi:
|
|
238
|
+
raise InvalidValue(
|
|
239
|
+
f"{field_name!r} was given {len(values)} values; declare it as Field(..., multi=True) "
|
|
240
|
+
"to combine them into an $in",
|
|
241
|
+
param=field_name,
|
|
242
|
+
)
|
|
243
|
+
filter_[column] = {"$in": values}
|
|
244
|
+
|
|
245
|
+
for column, clauses in operators.items():
|
|
246
|
+
if column in filter_:
|
|
247
|
+
raise InvalidValue(
|
|
248
|
+
f"{column!r} has both an equality match and {sorted(clauses)}; use one or the other", param=column
|
|
249
|
+
)
|
|
250
|
+
filter_[column] = clauses
|
|
251
|
+
|
|
252
|
+
# Every page, not just the ones reached by a cursor, needs a total order: two documents
|
|
253
|
+
# sharing a sort value could otherwise straddle the boundary and be skipped or repeated.
|
|
254
|
+
if cursors is not None and not any(column == cursors.id_field for column, _ in sort):
|
|
255
|
+
sort = [*sort, (cursors.id_field, 1)]
|
|
256
|
+
|
|
257
|
+
if after is not None:
|
|
258
|
+
if page is not None:
|
|
259
|
+
raise InvalidPagination(
|
|
260
|
+
f"{after_param} and {page_param} are different pagination modes; send only one", param=after_param
|
|
261
|
+
)
|
|
262
|
+
if cursors is None:
|
|
263
|
+
raise InvalidCursor(
|
|
264
|
+
f"{after_param} is not enabled; pass cursors=Cursors(...) to parse()", param=after_param
|
|
265
|
+
)
|
|
266
|
+
keyset = keyset_filter(sort, cursors.decode(after))
|
|
267
|
+
filter_ = {"$and": [filter_, keyset]} if filter_ else keyset
|
|
268
|
+
|
|
269
|
+
resolved_page = page or 1
|
|
270
|
+
return Query(
|
|
271
|
+
filter=filter_,
|
|
272
|
+
sort=sort,
|
|
273
|
+
projection=projection or None,
|
|
274
|
+
skip=0 if after is not None else (resolved_page - 1) * per_page,
|
|
275
|
+
limit=per_page,
|
|
276
|
+
page=resolved_page,
|
|
277
|
+
per_page=per_page,
|
|
278
|
+
cursors=cursors,
|
|
279
|
+
)
|
qsmongo/query.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The parse result: a filter, a projection, a sort, and a page window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .cursor import Cursors
|
|
9
|
+
from .errors import InvalidCursor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Query:
|
|
14
|
+
"""Everything needed to run the request against a collection.
|
|
15
|
+
|
|
16
|
+
``filter`` is a plain Mongo query document, so it composes with anything you already have::
|
|
17
|
+
|
|
18
|
+
collection.find({"$and": [tenant_scope, query.filter]})
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
filter: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
sort: list[tuple[str, int]] = field(default_factory=list)
|
|
23
|
+
projection: dict[str, int] | None = None
|
|
24
|
+
skip: int = 0
|
|
25
|
+
limit: int = 25
|
|
26
|
+
page: int = 1
|
|
27
|
+
per_page: int = 25
|
|
28
|
+
cursors: Cursors | None = None
|
|
29
|
+
|
|
30
|
+
def find_kwargs(self) -> dict[str, Any]:
|
|
31
|
+
"""Keyword arguments for ``collection.find(**query.find_kwargs())``."""
|
|
32
|
+
kwargs: dict[str, Any] = {"filter": self.filter, "skip": self.skip, "limit": self.limit}
|
|
33
|
+
if self.sort:
|
|
34
|
+
kwargs["sort"] = self.sort
|
|
35
|
+
if self.projection:
|
|
36
|
+
kwargs["projection"] = self.projection
|
|
37
|
+
return kwargs
|
|
38
|
+
|
|
39
|
+
def pipeline(self) -> list[dict[str, Any]]:
|
|
40
|
+
"""The same query as aggregation stages, for when you need to $lookup afterwards."""
|
|
41
|
+
stages: list[dict[str, Any]] = [{"$match": self.filter}]
|
|
42
|
+
if self.sort:
|
|
43
|
+
stages.append({"$sort": dict(self.sort)})
|
|
44
|
+
if self.skip:
|
|
45
|
+
stages.append({"$skip": self.skip})
|
|
46
|
+
stages.append({"$limit": self.limit})
|
|
47
|
+
if self.projection:
|
|
48
|
+
stages.append({"$project": self.projection})
|
|
49
|
+
return stages
|
|
50
|
+
|
|
51
|
+
def next_cursor(self, last_document: dict | None) -> str | None:
|
|
52
|
+
"""Cursor that resumes after ``last_document`` — the final document of this page.
|
|
53
|
+
|
|
54
|
+
Returns None for an empty page, which is also how a client knows it has reached the end.
|
|
55
|
+
Note that the cursor encodes the sort fields, so the next request must repeat the same
|
|
56
|
+
``sort``; sending a different one is rejected rather than silently paginating nonsense.
|
|
57
|
+
"""
|
|
58
|
+
if self.cursors is None:
|
|
59
|
+
raise InvalidCursor("cursor support is not enabled; pass cursors=Cursors(...) to parse()")
|
|
60
|
+
if not last_document:
|
|
61
|
+
return None
|
|
62
|
+
return self.cursors.encode(last_document, self.sort)
|
qsmongo/schema.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Field declarations — the whitelist that makes parsing safe.
|
|
2
|
+
|
|
3
|
+
Nothing reaches MongoDB unless it was declared here first. That is the whole security model:
|
|
4
|
+
an undeclared field is an error, never a pass-through.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
|
|
11
|
+
from .errors import UnknownField
|
|
12
|
+
|
|
13
|
+
try: # pragma: no cover - exercised only when pymongo is installed
|
|
14
|
+
from bson import ObjectId
|
|
15
|
+
except ImportError: # pragma: no cover
|
|
16
|
+
ObjectId = None
|
|
17
|
+
|
|
18
|
+
COMPARISON_OPS = frozenset({"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "exists"})
|
|
19
|
+
EQUALITY_OPS = frozenset({"eq", "ne", "in", "nin", "exists"})
|
|
20
|
+
# Substring matching is offered as three escaped, anchored operators rather than raw regex.
|
|
21
|
+
TEXT_MATCH_OPS = frozenset({"contains", "startswith", "endswith"})
|
|
22
|
+
# "regex" hands the pattern straight to Mongo, so it stays opt-in per field.
|
|
23
|
+
TEXT_OPS = EQUALITY_OPS | TEXT_MATCH_OPS
|
|
24
|
+
ALL_OPS = COMPARISON_OPS | TEXT_MATCH_OPS | {"regex"}
|
|
25
|
+
|
|
26
|
+
_DEFAULT_OPS: dict[type, frozenset[str]] = {
|
|
27
|
+
str: TEXT_OPS,
|
|
28
|
+
int: COMPARISON_OPS,
|
|
29
|
+
float: COMPARISON_OPS,
|
|
30
|
+
bool: frozenset({"eq", "ne", "exists"}),
|
|
31
|
+
datetime: COMPARISON_OPS,
|
|
32
|
+
date: COMPARISON_OPS,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Field:
|
|
37
|
+
"""One queryable field.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
type_: python type used to coerce incoming strings (str, int, float, bool, datetime, date, ObjectId).
|
|
41
|
+
ops: operators this field accepts. Defaults to a sensible set for the type.
|
|
42
|
+
sortable: whether the field may appear in the sort parameter.
|
|
43
|
+
multi: when True, a repeated key (``tag=a&tag=b``) collapses to ``$in`` instead of erroring.
|
|
44
|
+
alias: the document field name, when it differs from the name exposed in the API.
|
|
45
|
+
projectable: whether the field may be requested in the ``fields`` parameter.
|
|
46
|
+
case_sensitive: applies to contains/startswith/endswith. Case-insensitive matching cannot
|
|
47
|
+
use an ordinary index, so a case-sensitive ``startswith`` is the only substring
|
|
48
|
+
operator that stays fast on a large collection.
|
|
49
|
+
|
|
50
|
+
A field declared with ``ops=set()`` is not queryable at all — useful when you only want it to
|
|
51
|
+
be selectable via ``fields``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
__slots__ = ("type", "ops", "sortable", "multi", "alias", "projectable", "case_sensitive")
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
type_: type,
|
|
59
|
+
*,
|
|
60
|
+
ops: set[str] | frozenset[str] | None = None,
|
|
61
|
+
sortable: bool = True,
|
|
62
|
+
multi: bool = False,
|
|
63
|
+
alias: str | None = None,
|
|
64
|
+
projectable: bool = True,
|
|
65
|
+
case_sensitive: bool = False,
|
|
66
|
+
):
|
|
67
|
+
if ops is None:
|
|
68
|
+
ops = _DEFAULT_OPS.get(type_)
|
|
69
|
+
if ops is None:
|
|
70
|
+
ops = EQUALITY_OPS # ObjectId and anything custom
|
|
71
|
+
unknown = set(ops) - ALL_OPS
|
|
72
|
+
if unknown:
|
|
73
|
+
raise ValueError(f"unknown operator(s) for field: {sorted(unknown)}")
|
|
74
|
+
if type_ is not str and (set(ops) & (TEXT_MATCH_OPS | {"regex"})):
|
|
75
|
+
raise ValueError(f"text operators require a str field, got {type_.__name__}")
|
|
76
|
+
self.type = type_
|
|
77
|
+
self.ops = frozenset(ops)
|
|
78
|
+
self.sortable = sortable
|
|
79
|
+
self.multi = multi
|
|
80
|
+
self.alias = alias
|
|
81
|
+
self.projectable = projectable
|
|
82
|
+
self.case_sensitive = case_sensitive
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
return f"Field({self.type.__name__}, ops={sorted(self.ops)}, multi={self.multi})"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Schema:
|
|
89
|
+
"""A whitelist of queryable fields.
|
|
90
|
+
|
|
91
|
+
>>> Schema(name=Field(str), age=Field(int))
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
__slots__ = ("fields",)
|
|
95
|
+
|
|
96
|
+
def __init__(self, **fields: Field):
|
|
97
|
+
for name, field in fields.items():
|
|
98
|
+
if not isinstance(field, Field):
|
|
99
|
+
raise TypeError(f"{name!r} must be a Field, got {type(field).__name__}")
|
|
100
|
+
self.fields = fields
|
|
101
|
+
|
|
102
|
+
def get(self, name: str) -> Field:
|
|
103
|
+
try:
|
|
104
|
+
return self.fields[name]
|
|
105
|
+
except KeyError:
|
|
106
|
+
raise UnknownField(f"unknown field {name!r}", param=name) from None
|
|
107
|
+
|
|
108
|
+
def column(self, name: str) -> str:
|
|
109
|
+
"""Document field name for an API-facing field name."""
|
|
110
|
+
return self.get(name).alias or name
|
|
111
|
+
|
|
112
|
+
def __contains__(self, name: object) -> bool:
|
|
113
|
+
return name in self.fields
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str:
|
|
116
|
+
return f"Schema({', '.join(self.fields)})"
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qsmongo
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Turn an HTTP query string into a safe, typed MongoDB filter.
|
|
5
|
+
Project-URL: Homepage, https://github.com/boskodjokic/qsmongo
|
|
6
|
+
Project-URL: Issues, https://github.com/boskodjokic/qsmongo/issues
|
|
7
|
+
Author: Bosko Djokic
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Bosko Djokic
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: fastapi,filtering,mongodb,pagination,query string
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
38
|
+
Classifier: Topic :: Database
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
42
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# qsmongo
|
|
46
|
+
|
|
47
|
+
[](https://github.com/boskodjokic/qsmongo/actions/workflows/ci.yml)
|
|
48
|
+
[](https://www.python.org/)
|
|
49
|
+
[](LICENSE)
|
|
50
|
+
|
|
51
|
+
Turn an HTTP query string into a MongoDB query that is **safe by construction** — nothing is
|
|
52
|
+
queryable until you declare it — and **fast by construction**: keyset pagination that costs the
|
|
53
|
+
same on page 10,000 as on page 1.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
GET /users?age__gte=21&status__in=active,trial&fields=name,age&sort=-created_at&per_page=50
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from qsmongo import Field, Schema, parse
|
|
61
|
+
|
|
62
|
+
schema = Schema(
|
|
63
|
+
name=Field(str),
|
|
64
|
+
age=Field(int),
|
|
65
|
+
status=Field(str),
|
|
66
|
+
created_at=Field(datetime, alias="audit.created"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
query = parse(request.url.query, schema)
|
|
70
|
+
|
|
71
|
+
query.filter # {'age': {'$gte': 21}, 'status': {'$in': ['active', 'trial']}}
|
|
72
|
+
query.projection # {'name': 1, 'age': 1}
|
|
73
|
+
query.sort # [('audit.created', -1)]
|
|
74
|
+
|
|
75
|
+
collection.find(**query.find_kwargs())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
No dependencies. Python 3.10+.
|
|
79
|
+
|
|
80
|
+
## Why
|
|
81
|
+
|
|
82
|
+
Every REST API over MongoDB grows the same ad-hoc filter parser, and it goes wrong in the same
|
|
83
|
+
five ways:
|
|
84
|
+
|
|
85
|
+
1. **Type mismatch.** `?age=21` gives you the string `"21"`. Mongo does not match `21` against
|
|
86
|
+
`"21"`, so the endpoint returns an empty list and nobody can tell whether the data is missing or
|
|
87
|
+
the filter is broken.
|
|
88
|
+
2. **Operator injection.** Passing user input into a query document unfiltered means a crafted
|
|
89
|
+
parameter can inject `$where` or `$ne` and walk straight past your access checks.
|
|
90
|
+
3. **Separator collisions.** A product genuinely named `Dolce & Gabbana`, a colour called
|
|
91
|
+
`black and white`, a tag containing a comma — each one quietly truncates the filter.
|
|
92
|
+
4. **Unbounded pages.** `?per_page=100000` is a denial-of-service vector you shipped yourself.
|
|
93
|
+
5. **Offset pagination.** `skip=100000` makes the server walk 100,000 documents it will never
|
|
94
|
+
return, and when the sort key has ties, documents shift between pages and are skipped or served
|
|
95
|
+
twice.
|
|
96
|
+
|
|
97
|
+
## Install
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install qsmongo
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## The grammar
|
|
104
|
+
|
|
105
|
+
| Query string | Result |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `name=Ada` | `{"name": "Ada"}` |
|
|
108
|
+
| `age__gte=21` | `{"age": {"$gte": 21}}` |
|
|
109
|
+
| `age__gte=21&age__lt=65` | `{"age": {"$gte": 21, "$lt": 65}}` |
|
|
110
|
+
| `status__in=active,trial` | `{"status": {"$in": ["active", "trial"]}}` |
|
|
111
|
+
| `name__contains=ada` | `{"name": {"$regex": "ada", "$options": "i"}}` |
|
|
112
|
+
| `name__startswith=Ad` | `{"name": {"$regex": "^Ad", "$options": "i"}}` |
|
|
113
|
+
| `deleted_at__exists=false` | `{"deleted_at": {"$exists": False}}` |
|
|
114
|
+
| `fields=name,price` | `{"name": 1, "price": 1}` |
|
|
115
|
+
| `fields=-internal_note` | `{"internal_note": 0}` |
|
|
116
|
+
| `sort=-created_at,name` | `[("created_at", -1), ("name", 1)]` |
|
|
117
|
+
| `page=2&per_page=50` | `skip=50, limit=50` |
|
|
118
|
+
| `after=<cursor>&per_page=50` | keyset range clause, `skip=0` |
|
|
119
|
+
|
|
120
|
+
Operators: `ne` `gt` `gte` `lt` `lte` `in` `nin` `contains` `startswith` `endswith` `exists`
|
|
121
|
+
`regex`. No suffix means equality.
|
|
122
|
+
|
|
123
|
+
## Declaring fields
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
Schema(
|
|
127
|
+
name=Field(str), # eq/ne/in/nin/exists + contains/startswith/endswith
|
|
128
|
+
age=Field(int), # numbers also get gt/gte/lt/lte
|
|
129
|
+
email=Field(str, alias="contact.email"), # public name differs from the document field
|
|
130
|
+
tag=Field(str, multi=True), # ?tag=red&tag=blue -> $in
|
|
131
|
+
sku=Field(str, case_sensitive=True), # index-friendly startswith
|
|
132
|
+
internal_cost=Field(float, projectable=False), # never selectable via ?fields
|
|
133
|
+
description=Field(str, ops=set()), # selectable, never queryable
|
|
134
|
+
pattern=Field(str, ops={"eq", "regex"}), # raw regex is opt-in
|
|
135
|
+
)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
A field that is not declared cannot be queried, sorted on, projected, or smuggled in as an
|
|
139
|
+
operator — `parse` raises `UnknownField` instead. That whitelist *is* the security model.
|
|
140
|
+
|
|
141
|
+
## Keyset pagination
|
|
142
|
+
|
|
143
|
+
Offset paging is fine until it isn't. Pass a `Cursors` codec and clients can page by cursor:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from qsmongo import Cursors, parse
|
|
147
|
+
|
|
148
|
+
cursors = Cursors(secret=settings.CURSOR_SECRET) # HMAC-signed, so clients cannot forge one
|
|
149
|
+
|
|
150
|
+
query = parse(request.url.query, schema, cursors=cursors)
|
|
151
|
+
items = list(collection.find(**query.find_kwargs()))
|
|
152
|
+
|
|
153
|
+
return {"items": items, "next": query.next_cursor(items[-1] if items else None)}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The client sends that token back as `?after=<cursor>`, and the skip becomes a range clause:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
{"$or": [{"score": {"$lt": 42}}, {"score": 42, "_id": {"$gt": "abc"}}]}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Details that matter:
|
|
163
|
+
|
|
164
|
+
- **An `_id` tiebreaker is appended to every sort** when cursors are enabled, so the ordering is
|
|
165
|
+
total. Without it, two documents sharing a `created_at` can straddle a page boundary and one of
|
|
166
|
+
them is lost.
|
|
167
|
+
- **Cursors are signed** when you supply a secret, and the secret never appears in a `repr`.
|
|
168
|
+
Unsigned, tampered, or truncated tokens raise `InvalidCursor`.
|
|
169
|
+
- **The sort must stay the same between pages.** Changing it raises rather than silently
|
|
170
|
+
paginating nonsense.
|
|
171
|
+
- `after` and `page` are different modes; sending both is an error.
|
|
172
|
+
- Forward-only. Backward paging is not implemented.
|
|
173
|
+
|
|
174
|
+
[`tests/test_keyset_property.py`](tests/test_keyset_property.py) walks a dataset engineered so that
|
|
175
|
+
every page boundary lands in the middle of a tie, and asserts the pages reconstruct the full
|
|
176
|
+
ordering exactly — no document skipped, none repeated.
|
|
177
|
+
|
|
178
|
+
## Index advice
|
|
179
|
+
|
|
180
|
+
A query that parses cleanly can still be a collection scan. `analyze` checks the query against your
|
|
181
|
+
declared indexes using MongoDB's ESR ordering — **E**quality keys first, then **S**ort keys, then
|
|
182
|
+
**R**ange keys — and suggests one when nothing fits:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from qsmongo import Index, analyze
|
|
186
|
+
|
|
187
|
+
INDEXES = Index.from_index_information(collection.index_information())
|
|
188
|
+
|
|
189
|
+
advice = analyze(query, INDEXES, extra_equality=["tenant_id"])
|
|
190
|
+
if not advice.ok:
|
|
191
|
+
log.warning("unindexed query\n%s", advice)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
no index serves this query
|
|
196
|
+
- {status: 1, price: 1, audit.created: -1} filters this query but does not provide the sort
|
|
197
|
+
{audit.created: -1}, so MongoDB sorts in memory (it aborts past its blocking-sort memory
|
|
198
|
+
limit, 100 MB by default)
|
|
199
|
+
suggested index: {status: 1, audit.created: -1, price: 1}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
That example is the classic mistake: `{status, price, created_at}` looks sensible, but the range on
|
|
203
|
+
`price` sits between the equality key and the sort key, so the index stops providing the ordering.
|
|
204
|
+
Swapping the last two fixes it, and nothing about the query itself changes.
|
|
205
|
+
|
|
206
|
+
It also flags the predicates that cannot use an index at all — case-insensitive or unanchored
|
|
207
|
+
regex, `$ne`/`$nin`, `$exists: false` — reports covered queries, and understands that a sort on a
|
|
208
|
+
field pinned by an equality match is free. `extra_equality` is for the clauses your own code adds
|
|
209
|
+
(a tenant id, a soft-delete flag): they belong at the front of the index, and the advice is wrong
|
|
210
|
+
without them.
|
|
211
|
+
|
|
212
|
+
**This is a lint, not a query planner.** It reasons about the query shape, not your data
|
|
213
|
+
distribution or the real plan cache. `explain()` remains the only ground truth; this is here to
|
|
214
|
+
catch the obvious problems in CI or a dev-mode log, before they reach a database.
|
|
215
|
+
|
|
216
|
+
## Safety
|
|
217
|
+
|
|
218
|
+
- **Keys** are matched against the schema, so `$where=...` or `age__$gt=...` never reach the driver.
|
|
219
|
+
- **Values** are only ever coerced to the declared scalar type. Nothing is `eval`'d or parsed as
|
|
220
|
+
JSON, so a value of `{"$ne": null}` stays the harmless nine-character string it is.
|
|
221
|
+
- **Substring search is escaped.** `contains` / `startswith` / `endswith` run the value through
|
|
222
|
+
`re.escape` and anchor it, so `?name__contains=.*` looks for a literal `.*`. Raw `regex` is
|
|
223
|
+
opt-in per field and length-capped.
|
|
224
|
+
- **`per_page`** is clamped to `max_per_page` (default 100), so a client cannot ask for the
|
|
225
|
+
collection.
|
|
226
|
+
- **Projection is whitelisted**, so a field marked `projectable=False` cannot be selected even
|
|
227
|
+
when it is filterable.
|
|
228
|
+
|
|
229
|
+
Case-insensitive matching cannot use an ordinary index. `case_sensitive=True` drops the `i` option,
|
|
230
|
+
which is what makes `startswith` an index-friendly prefix scan on a large collection.
|
|
231
|
+
|
|
232
|
+
## Errors
|
|
233
|
+
|
|
234
|
+
All inherit `QSMongoError` (a `ValueError`) and carry the offending `.param`:
|
|
235
|
+
|
|
236
|
+
`UnknownField` · `UnsupportedOperator` · `InvalidValue` · `InvalidPagination` · `InvalidCursor` ·
|
|
237
|
+
`InvalidProjection`
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
try:
|
|
241
|
+
query = parse(request.url.query, schema, cursors=cursors)
|
|
242
|
+
except QSMongoError as exc:
|
|
243
|
+
raise HTTPException(status_code=400, detail={"parameter": exc.param, "error": str(exc)})
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
See [`examples/fastapi_app.py`](examples/fastapi_app.py) for a complete endpoint.
|
|
247
|
+
|
|
248
|
+
## Edge cases, on purpose
|
|
249
|
+
|
|
250
|
+
Covered in [`tests/test_edge_cases.py`](tests/test_edge_cases.py):
|
|
251
|
+
|
|
252
|
+
- `title=black and white` — the word `and` is a value, not a keyword.
|
|
253
|
+
- `title=Dolce%20%26%20Gabbana` — an encoded `&` stays inside the value.
|
|
254
|
+
- `title=Dolce & Gabbana` — an *unencoded* `&` cannot be recovered by any parser, so it raises
|
|
255
|
+
rather than silently searching for `Dolce`.
|
|
256
|
+
- `tag__in=red\,blue,green` — a backslash escapes a comma inside a list.
|
|
257
|
+
- `title=black+white` — `+` decodes to a space, per form encoding.
|
|
258
|
+
- `age=` — a blank value on a typed field is an error, not `0`.
|
|
259
|
+
|
|
260
|
+
## Composing with your own scope
|
|
261
|
+
|
|
262
|
+
`query.filter` is a plain dict, so multi-tenant scoping stays yours — the library never invents
|
|
263
|
+
clauses on your behalf:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
collection.find({"$and": [{"tenant_id": user.tenant_id}, query.filter]})
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Or use the aggregation form when you need `$lookup` afterwards:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
collection.aggregate([*query.pipeline(), {"$lookup": {...}}])
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Prior art
|
|
276
|
+
|
|
277
|
+
[`mongo-queries-manager`](https://pypi.org/project/mongo-queries-manager/) solves the same problem
|
|
278
|
+
and has more features — projection with `$elemMatch`, `$text`, custom casters. It takes the
|
|
279
|
+
opposite default: every field is queryable unless blacklisted, and types are inferred from the
|
|
280
|
+
value (`5.6` becomes a float, `true` becomes a bool).
|
|
281
|
+
|
|
282
|
+
Use it if you want breadth, or don't want to declare a schema.
|
|
283
|
+
|
|
284
|
+
Use `qsmongo` if you want an undeclared field to be an error rather than a silent leak, a numeric
|
|
285
|
+
SKU like `01234` to stay the string it is in your documents, and cursor pagination.
|
|
286
|
+
[`fastapi-filter`](https://pypi.org/project/fastapi-filter/) is also whitelist-based via pydantic
|
|
287
|
+
models, but is coupled to FastAPI and an ODM; `qsmongo` takes a string and returns a dict.
|
|
288
|
+
|
|
289
|
+
## Development
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
pip install -e ".[dev]"
|
|
293
|
+
pytest
|
|
294
|
+
ruff check .
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## License
|
|
298
|
+
|
|
299
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
qsmongo/__init__.py,sha256=PO7w0wG2Wf3cXOMFk9YjjM6xHwzV2TGfBr6YTTCk_yg,941
|
|
2
|
+
qsmongo/advisor.py,sha256=ZJR0agn6bEI2-AgvvOm7VNwgiiJpXRXvzob_acVLzlI,8180
|
|
3
|
+
qsmongo/coerce.py,sha256=w2Ouvy9U9G0NNqmSNJV6gBW3wZRPbamFuTKrPru7bGQ,2837
|
|
4
|
+
qsmongo/cursor.py,sha256=TwGLI01trz_jHH5dZr1tAF0XOk5XgFxKxGTr3y9WC-o,6654
|
|
5
|
+
qsmongo/errors.py,sha256=etwbbsUCLycCJ01rIrCXlZUt7vUTcK7F7i2UCp9A6-U,1154
|
|
6
|
+
qsmongo/parser.py,sha256=UMOD2XHOTQevVmcSkhbkDAVrdNc6GgGnpBF6l5RT2kI,10355
|
|
7
|
+
qsmongo/query.py,sha256=3nqdQ6CKZyGTPy2P3AIJabYS0knukn2eJP0Uzhd9OFs,2398
|
|
8
|
+
qsmongo/schema.py,sha256=LCKvWXhxaZD8ilH3OrLdBwpUc1xj_nDITZ4LTFayjrg,4284
|
|
9
|
+
qsmongo-0.3.0.dist-info/METADATA,sha256=oCS1PjFkJcO-v9uItC1ZsWkPDhEzITmSdU0zmTKQfv8,12597
|
|
10
|
+
qsmongo-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
11
|
+
qsmongo-0.3.0.dist-info/licenses/LICENSE,sha256=bIQgMfVLAHq7LdY5FIYUi1I5XlO77i01JnXhYq5esso,1069
|
|
12
|
+
qsmongo-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bosko Djokic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|