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.
- kernia_mongo-0.1.0/.gitignore +38 -0
- kernia_mongo-0.1.0/LICENSE +21 -0
- kernia_mongo-0.1.0/PKG-INFO +66 -0
- kernia_mongo-0.1.0/README.md +38 -0
- kernia_mongo-0.1.0/pyproject.toml +55 -0
- kernia_mongo-0.1.0/src/kernia_mongo/__init__.py +7 -0
- kernia_mongo-0.1.0/src/kernia_mongo/adapter.py +347 -0
- kernia_mongo-0.1.0/src/kernia_mongo/py.typed +0 -0
- kernia_mongo-0.1.0/tests/test_filter_translator.py +91 -0
|
@@ -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,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
|