kernia-mongo 0.1.0__tar.gz

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.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ .uv/
7
+ .mypy_cache/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
12
+ dist/
13
+ build/
14
+
15
+ # Editors
16
+ .idea/
17
+ .vscode/
18
+ *.swp
19
+ .DS_Store
20
+
21
+ # Docs build output
22
+ /site/
23
+ docs/site/
24
+
25
+ # Internal tooling (not part of the public repo)
26
+ scripts/audit_layout.py
27
+ scripts/setup_kernia_dns.sh
28
+ spec/
29
+
30
+ # Local
31
+
32
+ .projects/cache
33
+ .projects/vault
34
+ .projects/state.test.json
35
+ .projects/state.local.test.json
36
+ .env
37
+ .env.local
38
+ .env.test
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Advantch
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.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: kernia-mongo
3
+ Version: 0.1.0
4
+ Summary: MongoDB adapter for Kernia (motor)
5
+ Project-URL: Homepage, https://kernia.dev
6
+ Project-URL: Documentation, https://kernia.dev/docs
7
+ Project-URL: Source, https://github.com/advantch/kernia
8
+ Project-URL: Issues, https://github.com/advantch/kernia/issues
9
+ Project-URL: Changelog, https://github.com/advantch/kernia/releases
10
+ Author: Advantch
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: asgi,authentication,authorization,django,fastapi,oauth,passkeys,security,sessions,sso,starlette
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: Session
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: kernia>=0.1.0
26
+ Requires-Dist: motor>=3
27
+ Description-Content-Type: text/markdown
28
+
29
+ # kernia-mongo
30
+
31
+ MongoDB database adapter for Kernia, built on the async `motor` driver.
32
+
33
+ Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
34
+
35
+ ## Installation
36
+
37
+ pip install kernia-mongo
38
+
39
+ ## Usage
40
+
41
+ The adapter is an async factory: connect it, then pass it as `database`.
42
+
43
+ ```python
44
+ from kernia.auth import init
45
+ from kernia.plugins import email_and_password
46
+ from kernia.types.init_options import KerniaOptions
47
+ from kernia_mongo import mongo_adapter
48
+
49
+ adapter = await mongo_adapter(url="mongodb://localhost:27017", db_name="app")
50
+
51
+ auth = init(
52
+ KerniaOptions(
53
+ database=adapter,
54
+ secret="dev-secret",
55
+ plugins=[email_and_password()],
56
+ )
57
+ )
58
+ ```
59
+
60
+ ## Documentation
61
+
62
+ Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,38 @@
1
+ # kernia-mongo
2
+
3
+ MongoDB database adapter for Kernia, built on the async `motor` driver.
4
+
5
+ Part of [Kernia](https://kernia.dev), a framework-agnostic authentication library for Python.
6
+
7
+ ## Installation
8
+
9
+ pip install kernia-mongo
10
+
11
+ ## Usage
12
+
13
+ The adapter is an async factory: connect it, then pass it as `database`.
14
+
15
+ ```python
16
+ from kernia.auth import init
17
+ from kernia.plugins import email_and_password
18
+ from kernia.types.init_options import KerniaOptions
19
+ from kernia_mongo import mongo_adapter
20
+
21
+ adapter = await mongo_adapter(url="mongodb://localhost:27017", db_name="app")
22
+
23
+ auth = init(
24
+ KerniaOptions(
25
+ database=adapter,
26
+ secret="dev-secret",
27
+ plugins=[email_and_password()],
28
+ )
29
+ )
30
+ ```
31
+
32
+ ## Documentation
33
+
34
+ Full documentation at [kernia.dev/docs](https://kernia.dev/docs). Source at [github.com/advantch/kernia](https://github.com/advantch/kernia).
35
+
36
+ ## License
37
+
38
+ MIT
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "kernia-mongo"
3
+ version = "0.1.0"
4
+ description = "MongoDB adapter for Kernia (motor)"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "kernia>=0.1.0",
8
+ "motor>=3",
9
+ ]
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ license-files = [
13
+ "LICENSE",
14
+ ]
15
+ authors = [
16
+ {name = "Advantch"},
17
+ ]
18
+ keywords = [
19
+ "authentication",
20
+ "authorization",
21
+ "sessions",
22
+ "oauth",
23
+ "passkeys",
24
+ "sso",
25
+ "asgi",
26
+ "fastapi",
27
+ "starlette",
28
+ "django",
29
+ "security",
30
+ ]
31
+ classifiers = [
32
+ "Development Status :: 4 - Beta",
33
+ "Intended Audience :: Developers",
34
+ "Operating System :: OS Independent",
35
+ "Programming Language :: Python :: 3",
36
+ "Programming Language :: Python :: 3.11",
37
+ "Programming Language :: Python :: 3.12",
38
+ "Topic :: Software Development :: Libraries :: Python Modules",
39
+ "Topic :: Internet :: WWW/HTTP :: Session",
40
+ "Topic :: Security",
41
+ "Typing :: Typed",
42
+ ]
43
+
44
+ [project.urls]
45
+ Homepage = "https://kernia.dev"
46
+ Documentation = "https://kernia.dev/docs"
47
+ Source = "https://github.com/advantch/kernia"
48
+ Issues = "https://github.com/advantch/kernia/issues"
49
+ Changelog = "https://github.com/advantch/kernia/releases"
50
+ [build-system]
51
+ requires = ["hatchling"]
52
+ build-backend = "hatchling.build"
53
+
54
+ [tool.hatch.build.targets.wheel]
55
+ packages = ["src/kernia_mongo"]
@@ -0,0 +1,7 @@
1
+ """MongoDB adapter for Kernia."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kernia_mongo.adapter import MongoAdapter, mongo_adapter, where_to_bson
6
+
7
+ __all__ = ["MongoAdapter", "mongo_adapter", "where_to_bson"]
@@ -0,0 +1,347 @@
1
+ """MongoDB adapter — implements `CustomAdapter`, `ConsumingAdapter`, `SchemaAdapter`.
2
+
3
+ Wraps `motor.motor_asyncio.AsyncIOMotorClient`. Stores the better-auth `id` field
4
+ in MongoDB's `_id` column and translates between them transparently so callers never
5
+ see the underlying naming.
6
+
7
+ Mirrors `reference/packages/mongo-adapter/src/mongodb-adapter.ts`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import secrets
14
+ import time
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from kernia.db.schema import CORE_MODELS
20
+ from kernia.types.adapter import (
21
+ ConsumingAdapter,
22
+ CustomAdapter,
23
+ FieldDef,
24
+ JoinConfig,
25
+ ModelDef,
26
+ Record,
27
+ SchemaAdapter,
28
+ SortBy,
29
+ Where,
30
+ )
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # WhereOp -> BSON filter translator (pure function — heavily unit-tested)
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ _REGEX_META = re.compile(r"[.\*\+\?\^\$\{\}\(\)\|\[\]\\]")
38
+
39
+
40
+ def _escape_regex(s: str, max_length: int = 256) -> str:
41
+ """Escape regex special chars for safe `$regex` use."""
42
+ return _REGEX_META.sub(lambda m: "\\" + m.group(0), s[:max_length])
43
+
44
+
45
+ def _field_name(field: str) -> str:
46
+ """Map adapter-facing field names to MongoDB column names.
47
+
48
+ Only `id` is renamed (to `_id`); everything else is passed through.
49
+ """
50
+ return "_id" if field == "id" else field
51
+
52
+
53
+ def _clause_to_bson(clause: Where) -> dict[str, Any]:
54
+ """Translate one Where clause to a MongoDB filter fragment."""
55
+ field = _field_name(clause.field)
56
+ val = clause.value
57
+ op = clause.operator
58
+ match op:
59
+ case "eq":
60
+ return {field: val}
61
+ case "ne":
62
+ return {field: {"$ne": val}}
63
+ case "lt":
64
+ return {field: {"$lt": val}}
65
+ case "lte":
66
+ return {field: {"$lte": val}}
67
+ case "gt":
68
+ return {field: {"$gt": val}}
69
+ case "gte":
70
+ return {field: {"$gte": val}}
71
+ case "in":
72
+ return {field: {"$in": list(val)}}
73
+ case "not_in":
74
+ return {field: {"$nin": list(val)}}
75
+ case "contains":
76
+ return {field: {"$regex": f".*{_escape_regex(str(val))}.*"}}
77
+ case "starts_with":
78
+ return {field: {"$regex": f"^{_escape_regex(str(val))}"}}
79
+ case "ends_with":
80
+ return {field: {"$regex": f"{_escape_regex(str(val))}$"}}
81
+ case "ilike_eq":
82
+ return {field: {"$regex": f"^{_escape_regex(str(val))}$", "$options": "i"}}
83
+ case _: # pragma: no cover
84
+ raise ValueError(f"unsupported operator: {op}")
85
+
86
+
87
+ def where_to_bson(where: Sequence[Where]) -> dict[str, Any]:
88
+ """Translate a Sequence[Where] into a complete BSON filter.
89
+
90
+ Groups by connector — clauses with connector="AND" go under `$and`, those with
91
+ "OR" under `$or`. The first clause's connector is ignored (it has no left side).
92
+ A single clause is emitted bare without wrapping.
93
+ """
94
+ if not where:
95
+ return {}
96
+ if len(where) == 1:
97
+ return _clause_to_bson(where[0])
98
+ and_parts: list[dict[str, Any]] = []
99
+ or_parts: list[dict[str, Any]] = []
100
+ for i, clause in enumerate(where):
101
+ frag = _clause_to_bson(clause)
102
+ # First clause has no connector context — treat as AND.
103
+ if i == 0 or clause.connector == "AND":
104
+ and_parts.append(frag)
105
+ else:
106
+ or_parts.append(frag)
107
+ out: dict[str, Any] = {}
108
+ if and_parts:
109
+ out["$and"] = and_parts
110
+ if or_parts:
111
+ out["$or"] = or_parts
112
+ return out
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Id <-> _id transparent mapping
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def _to_mongo(data: Record) -> Record:
121
+ """Map adapter-facing `id` to MongoDB `_id` on insert/update."""
122
+ if "id" not in data:
123
+ return dict(data)
124
+ out = dict(data)
125
+ out["_id"] = out.pop("id")
126
+ return out
127
+
128
+
129
+ def _from_mongo(doc: dict[str, Any] | None) -> Record | None:
130
+ """Map MongoDB `_id` to adapter-facing `id` on read."""
131
+ if doc is None:
132
+ return None
133
+ out = dict(doc)
134
+ if "_id" in out:
135
+ out["id"] = out.pop("_id")
136
+ return out
137
+
138
+
139
+ def _project(row: Record, select: Sequence[str] | None) -> Record:
140
+ if not select:
141
+ return row
142
+ return {k: row[k] for k in select if k in row}
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Adapter
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ @dataclass
151
+ class MongoAdapter:
152
+ """Motor-backed adapter."""
153
+
154
+ db: Any # AsyncIOMotorDatabase — typed as Any to avoid hard dep at type-check time
155
+ models: tuple[ModelDef, ...] = ()
156
+
157
+ def _coll(self, model: str) -> Any:
158
+ return self.db[model]
159
+
160
+ async def create(
161
+ self,
162
+ *,
163
+ model: str,
164
+ data: Record,
165
+ select: Sequence[str] | None = None,
166
+ ) -> Record:
167
+ row = dict(data)
168
+ row.setdefault("id", secrets.token_urlsafe(16))
169
+ row.setdefault("createdAt", int(time.time()))
170
+ row.setdefault("updatedAt", int(time.time()))
171
+ doc = _to_mongo(row)
172
+ await self._coll(model).insert_one(doc)
173
+ out = _from_mongo(doc)
174
+ assert out is not None
175
+ return _project(out, select)
176
+
177
+ async def find_one(
178
+ self,
179
+ *,
180
+ model: str,
181
+ where: Sequence[Where],
182
+ select: Sequence[str] | None = None,
183
+ join: JoinConfig | None = None,
184
+ ) -> Record | None:
185
+ filt = where_to_bson(where)
186
+ doc = await self._coll(model).find_one(filt)
187
+ out = _from_mongo(doc)
188
+ if out is None:
189
+ return None
190
+ result = _project(out, select)
191
+ if join is not None:
192
+ foreign = await self.find_one(
193
+ model=join.model,
194
+ where=(Where(field=join.foreign_field, value=out.get(join.on)),),
195
+ )
196
+ result[join.as_] = foreign
197
+ return result
198
+
199
+ async def find_many(
200
+ self,
201
+ *,
202
+ model: str,
203
+ where: Sequence[Where] = (),
204
+ limit: int | None = None,
205
+ offset: int | None = None,
206
+ sort_by: SortBy | None = None,
207
+ select: Sequence[str] | None = None,
208
+ join: JoinConfig | None = None,
209
+ ) -> list[Record]:
210
+ filt = where_to_bson(where)
211
+ cursor = self._coll(model).find(filt)
212
+ if sort_by is not None:
213
+ mongo_field = _field_name(sort_by.field)
214
+ direction = -1 if sort_by.direction == "desc" else 1
215
+ cursor = cursor.sort(mongo_field, direction)
216
+ if offset:
217
+ cursor = cursor.skip(offset)
218
+ if limit is not None:
219
+ cursor = cursor.limit(limit)
220
+ docs = await cursor.to_list(length=limit if limit is not None else None)
221
+ out: list[Record] = []
222
+ for d in docs:
223
+ row = _from_mongo(d)
224
+ assert row is not None
225
+ row = _project(row, select)
226
+ if join is not None:
227
+ row[join.as_] = await self.find_one(
228
+ model=join.model,
229
+ where=(Where(field=join.foreign_field, value=row.get(join.on)),),
230
+ )
231
+ out.append(row)
232
+ return out
233
+
234
+ async def update(
235
+ self,
236
+ *,
237
+ model: str,
238
+ where: Sequence[Where],
239
+ update: Record,
240
+ ) -> Record | None:
241
+ filt = where_to_bson(where)
242
+ patch = dict(update)
243
+ patch["updatedAt"] = int(time.time())
244
+ doc = await self._coll(model).find_one_and_update(
245
+ filt,
246
+ {"$set": patch},
247
+ return_document=True, # ReturnDocument.AFTER
248
+ )
249
+ # Motor uses pymongo.ReturnDocument; True == AFTER (1)
250
+ return _from_mongo(doc)
251
+
252
+ async def update_many(
253
+ self,
254
+ *,
255
+ model: str,
256
+ where: Sequence[Where],
257
+ update: Record,
258
+ ) -> int:
259
+ filt = where_to_bson(where)
260
+ patch = dict(update)
261
+ patch["updatedAt"] = int(time.time())
262
+ result = await self._coll(model).update_many(filt, {"$set": patch})
263
+ return int(result.modified_count)
264
+
265
+ async def delete(
266
+ self,
267
+ *,
268
+ model: str,
269
+ where: Sequence[Where],
270
+ ) -> None:
271
+ filt = where_to_bson(where)
272
+ await self._coll(model).delete_one(filt)
273
+
274
+ async def delete_many(
275
+ self,
276
+ *,
277
+ model: str,
278
+ where: Sequence[Where],
279
+ ) -> int:
280
+ filt = where_to_bson(where)
281
+ result = await self._coll(model).delete_many(filt)
282
+ return int(result.deleted_count)
283
+
284
+ async def count(
285
+ self,
286
+ *,
287
+ model: str,
288
+ where: Sequence[Where] = (),
289
+ ) -> int:
290
+ filt = where_to_bson(where)
291
+ return int(await self._coll(model).count_documents(filt))
292
+
293
+ async def consume_one(
294
+ self,
295
+ *,
296
+ model: str,
297
+ where: Sequence[Where],
298
+ ) -> Record | None:
299
+ filt = where_to_bson(where)
300
+ doc = await self._coll(model).find_one_and_delete(filt)
301
+ return _from_mongo(doc)
302
+
303
+ async def create_schema(self, *, models: Sequence[ModelDef]) -> None:
304
+ """Create indexes for fields marked unique.
305
+
306
+ MongoDB itself creates collections on first insert, so we only need to
307
+ materialize the unique constraints up-front. `_id` is implicitly unique.
308
+ """
309
+ for model in models:
310
+ for f in model.fields:
311
+ if not f.unique:
312
+ continue
313
+ if f.name == "id":
314
+ continue # _id is unique by Mongo's contract
315
+ await self._coll(model.name).create_index(f.name, unique=True)
316
+
317
+
318
+ # Reference the optional Protocols to keep them in the package's import graph and
319
+ # make explicit which contracts MongoAdapter satisfies.
320
+ _PROTOCOLS: tuple[type, ...] = (CustomAdapter, ConsumingAdapter, SchemaAdapter)
321
+
322
+
323
+ async def mongo_adapter(
324
+ *,
325
+ url: str,
326
+ db_name: str = "kernia",
327
+ extra_models: Sequence[ModelDef] = (),
328
+ **kwargs: Any,
329
+ ) -> CustomAdapter:
330
+ """Build a MongoDB adapter, connect, and materialize core indexes.
331
+
332
+ `url` is a standard `mongodb://` URI. `db_name` selects the database within
333
+ the cluster. Any additional `kwargs` are forwarded to `AsyncIOMotorClient`.
334
+ """
335
+ from motor.motor_asyncio import AsyncIOMotorClient # local import — optional dep
336
+
337
+ client = AsyncIOMotorClient(url, **kwargs)
338
+ db = client[db_name]
339
+ models = tuple(CORE_MODELS) + tuple(extra_models)
340
+ adapter = MongoAdapter(db=db, models=models)
341
+ await adapter.create_schema(models=models)
342
+ return adapter
343
+
344
+
345
+ # Re-exported helper for FieldDef introspection — used by tests.
346
+ def is_unique_field(f: FieldDef) -> bool:
347
+ return f.unique
File without changes
@@ -0,0 +1,91 @@
1
+ """Unit tests for the Where -> BSON translator.
2
+
3
+ Pure-function tests — no Docker, no I/O. Locks in the wire shape of the BSON
4
+ filters our MongoDB adapter emits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from kernia.types.adapter import Where
10
+ from kernia_mongo.adapter import _escape_regex, _field_name, where_to_bson
11
+
12
+
13
+ def test_empty_where_is_empty_filter() -> None:
14
+ assert where_to_bson(()) == {}
15
+
16
+
17
+ def test_single_eq_is_bare() -> None:
18
+ assert where_to_bson((Where("email", "a@example.com"),)) == {"email": "a@example.com"}
19
+
20
+
21
+ def test_id_field_maps_to_underscore_id() -> None:
22
+ assert where_to_bson((Where("id", "abc"),)) == {"_id": "abc"}
23
+ assert _field_name("id") == "_id"
24
+ assert _field_name("userId") == "userId"
25
+
26
+
27
+ def test_ne_operator() -> None:
28
+ assert where_to_bson((Where("name", "x", operator="ne"),)) == {"name": {"$ne": "x"}}
29
+
30
+
31
+ def test_numeric_operators() -> None:
32
+ assert where_to_bson((Where("age", 30, operator="gt"),)) == {"age": {"$gt": 30}}
33
+ assert where_to_bson((Where("age", 30, operator="gte"),)) == {"age": {"$gte": 30}}
34
+ assert where_to_bson((Where("age", 30, operator="lt"),)) == {"age": {"$lt": 30}}
35
+ assert where_to_bson((Where("age", 30, operator="lte"),)) == {"age": {"$lte": 30}}
36
+
37
+
38
+ def test_in_and_not_in() -> None:
39
+ assert where_to_bson((Where("status", ["a", "b"], operator="in"),)) == {
40
+ "status": {"$in": ["a", "b"]}
41
+ }
42
+ assert where_to_bson((Where("status", ["a"], operator="not_in"),)) == {
43
+ "status": {"$nin": ["a"]}
44
+ }
45
+
46
+
47
+ def test_contains_starts_with_ends_with_escape() -> None:
48
+ f = where_to_bson((Where("email", "a.b", operator="contains"),))
49
+ assert f == {"email": {"$regex": ".*a\\.b.*"}}
50
+ f = where_to_bson((Where("email", "a.b", operator="starts_with"),))
51
+ assert f == {"email": {"$regex": "^a\\.b"}}
52
+ f = where_to_bson((Where("email", "a.b", operator="ends_with"),))
53
+ assert f == {"email": {"$regex": "a\\.b$"}}
54
+
55
+
56
+ def test_ilike_eq_uses_case_insensitive_regex() -> None:
57
+ assert where_to_bson((Where("email", "A@X.com", operator="ilike_eq"),)) == {
58
+ "email": {"$regex": "^A@X\\.com$", "$options": "i"}
59
+ }
60
+
61
+
62
+ def test_and_connector_groups_under_and() -> None:
63
+ out = where_to_bson(
64
+ (
65
+ Where("name", "x"),
66
+ Where("age", 30, operator="gt", connector="AND"),
67
+ )
68
+ )
69
+ assert out == {"$and": [{"name": "x"}, {"age": {"$gt": 30}}]}
70
+
71
+
72
+ def test_or_connector_groups_under_or() -> None:
73
+ out = where_to_bson(
74
+ (
75
+ Where("name", "x"),
76
+ Where("name", "y", connector="OR"),
77
+ )
78
+ )
79
+ # First clause's connector is treated as AND; second goes to $or.
80
+ assert out == {"$and": [{"name": "x"}], "$or": [{"name": "y"}]}
81
+
82
+
83
+ def test_escape_regex_escapes_meta_chars() -> None:
84
+ s = _escape_regex(".*+?^${}()|[]\\")
85
+ assert s == "\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\"
86
+
87
+
88
+ def test_escape_regex_truncates_to_max_length() -> None:
89
+ s = _escape_regex("a" * 1000, max_length=10)
90
+ # 10 'a's, none of which are meta chars.
91
+ assert s == "a" * 10