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 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
+ [![CI](https://github.com/boskodjokic/qsmongo/actions/workflows/ci.yml/badge.svg)](https://github.com/boskodjokic/qsmongo/actions/workflows/ci.yml)
48
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
49
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.