nitrodb 2.4.3__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.
- nedb/__init__.py +92 -0
- nedb/autoindex.py +142 -0
- nedb/backends/__init__.py +0 -0
- nedb/backends/redis_backend.py +115 -0
- nedb/cascade.py +130 -0
- nedb/concurrent.py +218 -0
- nedb/crypto.py +294 -0
- nedb/engine.py +783 -0
- nedb/index.py +98 -0
- nedb/log.py +216 -0
- nedb/merkle.py +62 -0
- nedb/mongo.py +824 -0
- nedb/proof.py +126 -0
- nedb/query.py +305 -0
- nedb/redis_compat.py +516 -0
- nedb/relations.py +51 -0
- nedb/resp2.py +250 -0
- nedb/server.py +1011 -0
- nedb/snapshot.py +216 -0
- nedb/sql.py +430 -0
- nedb/store.py +68 -0
- nedb/wrap_redis.py +725 -0
- nitrodb-2.4.3.dist-info/METADATA +64 -0
- nitrodb-2.4.3.dist-info/RECORD +27 -0
- nitrodb-2.4.3.dist-info/WHEEL +4 -0
- nitrodb-2.4.3.dist-info/entry_points.txt +2 -0
- nitrodb-2.4.3.dist-info/licenses/LICENSE +65 -0
nedb/mongo.py
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nedb.mongo — MongoDB compatibility adapter.
|
|
3
|
+
|
|
4
|
+
Maps the MongoDB document/collection API deterministically onto NEDB primitives.
|
|
5
|
+
No pymongo, bson, or MongoDB server code is used or required — the MongoDB API is
|
|
6
|
+
simply a familiar entry point; the NEDB engine executes everything natively using
|
|
7
|
+
its append-only log, MVCC store, relations, and indexes (so every write is
|
|
8
|
+
replay-protected and hash-chained, and time-travel still holds).
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from nedb import NEDB
|
|
13
|
+
from nedb.mongo import MongoCompat
|
|
14
|
+
|
|
15
|
+
db = NEDB("./data")
|
|
16
|
+
mongo = MongoCompat(db)
|
|
17
|
+
users = mongo["users"] # or mongo.collection("users")
|
|
18
|
+
|
|
19
|
+
users.insert_one({"name": "Ada", "age": 31, "status": "active"})
|
|
20
|
+
users.insert_many([{"name": "Bob", "age": 24}, {"name": "Carol", "age": 41}])
|
|
21
|
+
|
|
22
|
+
users.find_one({"name": "Ada"}) # → {"_id": ..., "name": "Ada", ...}
|
|
23
|
+
list(users.find({"age": {"$gt": 25}}).sort("age", -1).limit(10))
|
|
24
|
+
users.update_one({"name": "Ada"}, {"$set": {"age": 32}, "$inc": {"logins": 1}})
|
|
25
|
+
users.delete_many({"status": "inactive"})
|
|
26
|
+
users.count_documents({"status": "active"})
|
|
27
|
+
users.distinct("status")
|
|
28
|
+
users.aggregate([{"$group": {"_id": "$status", "n": {"$sum": 1}}}])
|
|
29
|
+
|
|
30
|
+
MongoDB → NEDB mapping
|
|
31
|
+
──────────────────────
|
|
32
|
+
collection name → NEDB collection
|
|
33
|
+
document → NEDB doc (id = str(_id); ObjectId auto-generated if absent)
|
|
34
|
+
filter operators → $eq $ne $gt $gte $lt $lte $in $nin $exists $regex
|
|
35
|
+
$and $or $nor $not $size $all $mod (dotted paths supported)
|
|
36
|
+
update operators → $set $unset $inc $mul $min $max $rename $push $addToSet
|
|
37
|
+
$pull $pop $setOnInsert (+ full-document replacement)
|
|
38
|
+
find cursor → .sort() .skip() .limit() .to_list() (lazy, chainable)
|
|
39
|
+
aggregate stages → $match $group $sort $skip $limit $count $project
|
|
40
|
+
accumulators → $sum $avg $min $max $first $last $push $addToSet
|
|
41
|
+
|
|
42
|
+
Equality-indexed fields (db.create_index) accelerate filters automatically; any
|
|
43
|
+
filter the planner can't express as a simple AND of comparisons falls back to a
|
|
44
|
+
correctness-guaranteed in-engine scan + Python match.
|
|
45
|
+
|
|
46
|
+
Unsupported (raise MongoUnsupportedError): $where/JS, $text $search index search
|
|
47
|
+
via runCommand, $lookup/$unwind/$facet aggregation stages, GridFS, change streams,
|
|
48
|
+
multi-document transactions (sessions), map-reduce, geospatial operators.
|
|
49
|
+
"""
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import binascii
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import random
|
|
56
|
+
import re
|
|
57
|
+
import struct
|
|
58
|
+
import time
|
|
59
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
|
60
|
+
|
|
61
|
+
from .query import empty_plan
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Errors ──────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
class MongoError(Exception):
|
|
67
|
+
"""Raised on a MongoDB-compatible usage or argument error."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MongoUnsupportedError(MongoError):
|
|
71
|
+
"""Raised when a MongoDB feature is not yet implemented in NEDB."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── ObjectId ────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
_OID_COUNTER = random.randint(0, 0xFFFFFF)
|
|
77
|
+
_OID_MACHINE = os.urandom(5)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def ObjectId() -> str:
|
|
81
|
+
"""Generate a MongoDB-style 24-hex-char ObjectId.
|
|
82
|
+
|
|
83
|
+
Layout matches MongoDB: 4-byte timestamp + 5-byte random + 3-byte counter.
|
|
84
|
+
Returned as a plain ``str`` so it is JSON-serializable and round-trips through
|
|
85
|
+
NEDB's log unchanged (no bson dependency).
|
|
86
|
+
"""
|
|
87
|
+
global _OID_COUNTER
|
|
88
|
+
_OID_COUNTER = (_OID_COUNTER + 1) & 0xFFFFFF
|
|
89
|
+
raw = struct.pack(">I", int(time.time())) + _OID_MACHINE + struct.pack(">I", _OID_COUNTER)[1:]
|
|
90
|
+
return binascii.hexlify(raw).decode()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Result objects (mirror pymongo) ──────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
class InsertOneResult:
|
|
96
|
+
__slots__ = ("inserted_id", "acknowledged")
|
|
97
|
+
|
|
98
|
+
def __init__(self, inserted_id: Any):
|
|
99
|
+
self.inserted_id = inserted_id
|
|
100
|
+
self.acknowledged = True
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f"InsertOneResult({self.inserted_id!r})"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InsertManyResult:
|
|
107
|
+
__slots__ = ("inserted_ids", "acknowledged")
|
|
108
|
+
|
|
109
|
+
def __init__(self, inserted_ids: List[Any]):
|
|
110
|
+
self.inserted_ids = inserted_ids
|
|
111
|
+
self.acknowledged = True
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
return f"InsertManyResult({self.inserted_ids!r})"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class UpdateResult:
|
|
118
|
+
__slots__ = ("matched_count", "modified_count", "upserted_id", "acknowledged")
|
|
119
|
+
|
|
120
|
+
def __init__(self, matched: int, modified: int, upserted_id: Any = None):
|
|
121
|
+
self.matched_count = matched
|
|
122
|
+
self.modified_count = modified
|
|
123
|
+
self.upserted_id = upserted_id
|
|
124
|
+
self.acknowledged = True
|
|
125
|
+
|
|
126
|
+
def __repr__(self) -> str:
|
|
127
|
+
return (f"UpdateResult(matched={self.matched_count}, "
|
|
128
|
+
f"modified={self.modified_count}, upserted_id={self.upserted_id!r})")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DeleteResult:
|
|
132
|
+
__slots__ = ("deleted_count", "acknowledged")
|
|
133
|
+
|
|
134
|
+
def __init__(self, deleted: int):
|
|
135
|
+
self.deleted_count = deleted
|
|
136
|
+
self.acknowledged = True
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str:
|
|
139
|
+
return f"DeleteResult(deleted={self.deleted_count})"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Path + comparison helpers ─────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
_MISSING = object() # sentinel distinguishing "field absent" from "field is null"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_path(doc: Any, path: str) -> Any:
|
|
148
|
+
"""Resolve a dotted field path ('a.b.c'); return _MISSING if any hop is absent."""
|
|
149
|
+
cur = doc
|
|
150
|
+
for part in path.split("."):
|
|
151
|
+
if isinstance(cur, dict) and part in cur:
|
|
152
|
+
cur = cur[part]
|
|
153
|
+
else:
|
|
154
|
+
return _MISSING
|
|
155
|
+
return cur
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _set_path(doc: dict, path: str, value: Any) -> None:
|
|
159
|
+
"""Set a dotted field path, creating intermediate dicts as needed."""
|
|
160
|
+
parts = path.split(".")
|
|
161
|
+
cur = doc
|
|
162
|
+
for part in parts[:-1]:
|
|
163
|
+
nxt = cur.get(part)
|
|
164
|
+
if not isinstance(nxt, dict):
|
|
165
|
+
nxt = {}
|
|
166
|
+
cur[part] = nxt
|
|
167
|
+
cur = nxt
|
|
168
|
+
cur[parts[-1]] = value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _unset_path(doc: dict, path: str) -> None:
|
|
172
|
+
parts = path.split(".")
|
|
173
|
+
cur = doc
|
|
174
|
+
for part in parts[:-1]:
|
|
175
|
+
cur = cur.get(part)
|
|
176
|
+
if not isinstance(cur, dict):
|
|
177
|
+
return
|
|
178
|
+
cur.pop(parts[-1], None)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _eq(actual: Any, operand: Any) -> bool:
|
|
182
|
+
"""MongoDB equality: null matches missing-or-null; scalar matches array membership."""
|
|
183
|
+
if actual is _MISSING:
|
|
184
|
+
return operand is None
|
|
185
|
+
if isinstance(actual, list) and not isinstance(operand, list):
|
|
186
|
+
return operand in actual or actual == operand
|
|
187
|
+
return actual == operand
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _cmp(actual: Any, operand: Any, fn) -> bool:
|
|
191
|
+
"""Type-safe comparison; array operands match if ANY element satisfies (Mongo semantics)."""
|
|
192
|
+
if actual is _MISSING or actual is None:
|
|
193
|
+
return False
|
|
194
|
+
if isinstance(actual, list):
|
|
195
|
+
return any(_cmp(a, operand, fn) for a in actual)
|
|
196
|
+
try:
|
|
197
|
+
return bool(fn(actual, operand))
|
|
198
|
+
except TypeError:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _sort_key(v: Any):
|
|
203
|
+
"""Total order across mixed BSON-ish types so heterogeneous sorts never raise."""
|
|
204
|
+
if v is _MISSING or v is None:
|
|
205
|
+
return (0, 0)
|
|
206
|
+
if isinstance(v, bool):
|
|
207
|
+
return (1, int(v))
|
|
208
|
+
if isinstance(v, (int, float)):
|
|
209
|
+
return (2, v)
|
|
210
|
+
if isinstance(v, str):
|
|
211
|
+
return (3, v)
|
|
212
|
+
return (4, str(v))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ── Filter matching ───────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def _match(doc: dict, filt: dict) -> bool:
|
|
218
|
+
"""Return True if ``doc`` satisfies a MongoDB filter document."""
|
|
219
|
+
for key, cond in filt.items():
|
|
220
|
+
if key == "$and":
|
|
221
|
+
if not all(_match(doc, sub) for sub in cond):
|
|
222
|
+
return False
|
|
223
|
+
elif key == "$or":
|
|
224
|
+
if not any(_match(doc, sub) for sub in cond):
|
|
225
|
+
return False
|
|
226
|
+
elif key == "$nor":
|
|
227
|
+
if any(_match(doc, sub) for sub in cond):
|
|
228
|
+
return False
|
|
229
|
+
elif key == "$not":
|
|
230
|
+
if _match(doc, cond):
|
|
231
|
+
return False
|
|
232
|
+
elif key.startswith("$"):
|
|
233
|
+
raise MongoUnsupportedError(f"Unsupported top-level operator {key!r}")
|
|
234
|
+
else:
|
|
235
|
+
if not _match_field(_get_path(doc, key), cond):
|
|
236
|
+
return False
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _is_operator_doc(cond: Any) -> bool:
|
|
241
|
+
return isinstance(cond, dict) and len(cond) > 0 and all(k.startswith("$") for k in cond)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _match_field(actual: Any, cond: Any) -> bool:
|
|
245
|
+
if _is_operator_doc(cond):
|
|
246
|
+
options = cond.get("$options", "")
|
|
247
|
+
for op, operand in cond.items():
|
|
248
|
+
if op == "$options":
|
|
249
|
+
continue
|
|
250
|
+
if not _match_op(actual, op, operand, options):
|
|
251
|
+
return False
|
|
252
|
+
return True
|
|
253
|
+
return _eq(actual, cond)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _match_op(actual: Any, op: str, operand: Any, options: str = "") -> bool:
|
|
257
|
+
if op == "$eq":
|
|
258
|
+
return _eq(actual, operand)
|
|
259
|
+
if op == "$ne":
|
|
260
|
+
return not _eq(actual, operand)
|
|
261
|
+
if op == "$gt":
|
|
262
|
+
return _cmp(actual, operand, lambda a, b: a > b)
|
|
263
|
+
if op == "$gte":
|
|
264
|
+
return _cmp(actual, operand, lambda a, b: a >= b)
|
|
265
|
+
if op == "$lt":
|
|
266
|
+
return _cmp(actual, operand, lambda a, b: a < b)
|
|
267
|
+
if op == "$lte":
|
|
268
|
+
return _cmp(actual, operand, lambda a, b: a <= b)
|
|
269
|
+
if op == "$in":
|
|
270
|
+
if actual is _MISSING:
|
|
271
|
+
return any(v is None for v in operand)
|
|
272
|
+
if isinstance(actual, list):
|
|
273
|
+
return any(a in operand for a in actual)
|
|
274
|
+
return actual in operand
|
|
275
|
+
if op == "$nin":
|
|
276
|
+
return not _match_op(actual, "$in", operand, options)
|
|
277
|
+
if op == "$exists":
|
|
278
|
+
return (actual is not _MISSING) == bool(operand)
|
|
279
|
+
if op == "$regex":
|
|
280
|
+
if actual is _MISSING or not isinstance(actual, str):
|
|
281
|
+
return False
|
|
282
|
+
flags = re.IGNORECASE if "i" in options else 0
|
|
283
|
+
if "m" in options:
|
|
284
|
+
flags |= re.MULTILINE
|
|
285
|
+
if "s" in options:
|
|
286
|
+
flags |= re.DOTALL
|
|
287
|
+
try:
|
|
288
|
+
return re.search(operand, actual, flags) is not None
|
|
289
|
+
except re.error as e:
|
|
290
|
+
raise MongoError(f"invalid $regex: {e}")
|
|
291
|
+
if op == "$not":
|
|
292
|
+
return not _match_field(actual, operand)
|
|
293
|
+
if op == "$size":
|
|
294
|
+
return isinstance(actual, list) and len(actual) == operand
|
|
295
|
+
if op == "$all":
|
|
296
|
+
return isinstance(actual, list) and all(x in actual for x in operand)
|
|
297
|
+
if op == "$mod":
|
|
298
|
+
if actual is _MISSING or not isinstance(actual, (int, float)):
|
|
299
|
+
return False
|
|
300
|
+
divisor, remainder = operand
|
|
301
|
+
try:
|
|
302
|
+
return int(actual) % int(divisor) == int(remainder)
|
|
303
|
+
except (ZeroDivisionError, TypeError, ValueError):
|
|
304
|
+
return False
|
|
305
|
+
if op == "$elemMatch":
|
|
306
|
+
if not isinstance(actual, list):
|
|
307
|
+
return False
|
|
308
|
+
return any(
|
|
309
|
+
_match(item, operand) if not _is_operator_doc(operand) else _match_field(item, operand)
|
|
310
|
+
for item in actual
|
|
311
|
+
)
|
|
312
|
+
raise MongoUnsupportedError(f"Unsupported query operator {op!r}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── Projection ────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def _project(doc: dict, projection: Optional[dict]) -> dict:
|
|
318
|
+
if not projection:
|
|
319
|
+
return doc
|
|
320
|
+
include = {k: v for k, v in projection.items() if k != "_id"}
|
|
321
|
+
if not include:
|
|
322
|
+
# exclusion-only (possibly with _id:0)
|
|
323
|
+
out = {k: v for k, v in doc.items()}
|
|
324
|
+
for k, v in projection.items():
|
|
325
|
+
if not v:
|
|
326
|
+
out.pop(k, None)
|
|
327
|
+
return out
|
|
328
|
+
modes = set(bool(v) for v in include.values())
|
|
329
|
+
if modes == {True}:
|
|
330
|
+
out = {k: doc[k] for k in include if k in doc}
|
|
331
|
+
if projection.get("_id", 1):
|
|
332
|
+
if "_id" in doc:
|
|
333
|
+
out["_id"] = doc["_id"]
|
|
334
|
+
return out
|
|
335
|
+
if modes == {False}:
|
|
336
|
+
out = {k: v for k, v in doc.items()}
|
|
337
|
+
for k, v in include.items():
|
|
338
|
+
if not v:
|
|
339
|
+
out.pop(k, None)
|
|
340
|
+
if not projection.get("_id", 1):
|
|
341
|
+
out.pop("_id", None)
|
|
342
|
+
return out
|
|
343
|
+
raise MongoError("Projection cannot mix inclusion and exclusion (except _id).")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ── Update operators ──────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
def _apply_update(existing: Optional[dict], update: dict, *, on_insert: bool = False) -> dict:
|
|
349
|
+
"""Apply a MongoDB update document. A doc with no $-operators is a full replacement."""
|
|
350
|
+
has_ops = any(k.startswith("$") for k in update)
|
|
351
|
+
if not has_ops:
|
|
352
|
+
# full-document replacement — _id is preserved by the caller
|
|
353
|
+
return dict(update)
|
|
354
|
+
|
|
355
|
+
doc = dict(existing or {})
|
|
356
|
+
for op, spec in update.items():
|
|
357
|
+
if op == "$set":
|
|
358
|
+
for field, value in spec.items():
|
|
359
|
+
_set_path(doc, field, value)
|
|
360
|
+
elif op == "$setOnInsert":
|
|
361
|
+
if on_insert:
|
|
362
|
+
for field, value in spec.items():
|
|
363
|
+
_set_path(doc, field, value)
|
|
364
|
+
elif op == "$unset":
|
|
365
|
+
for field in spec:
|
|
366
|
+
_unset_path(doc, field)
|
|
367
|
+
elif op == "$inc":
|
|
368
|
+
for field, delta in spec.items():
|
|
369
|
+
cur = _get_path(doc, field)
|
|
370
|
+
base = cur if isinstance(cur, (int, float)) and not isinstance(cur, bool) else 0
|
|
371
|
+
_set_path(doc, field, base + delta)
|
|
372
|
+
elif op == "$mul":
|
|
373
|
+
for field, factor in spec.items():
|
|
374
|
+
cur = _get_path(doc, field)
|
|
375
|
+
base = cur if isinstance(cur, (int, float)) and not isinstance(cur, bool) else 0
|
|
376
|
+
_set_path(doc, field, base * factor)
|
|
377
|
+
elif op == "$min":
|
|
378
|
+
for field, value in spec.items():
|
|
379
|
+
cur = _get_path(doc, field)
|
|
380
|
+
if cur is _MISSING or _sort_key(value) < _sort_key(cur):
|
|
381
|
+
_set_path(doc, field, value)
|
|
382
|
+
elif op == "$max":
|
|
383
|
+
for field, value in spec.items():
|
|
384
|
+
cur = _get_path(doc, field)
|
|
385
|
+
if cur is _MISSING or _sort_key(value) > _sort_key(cur):
|
|
386
|
+
_set_path(doc, field, value)
|
|
387
|
+
elif op == "$rename":
|
|
388
|
+
for field, new_field in spec.items():
|
|
389
|
+
cur = _get_path(doc, field)
|
|
390
|
+
if cur is not _MISSING:
|
|
391
|
+
_unset_path(doc, field)
|
|
392
|
+
_set_path(doc, new_field, cur)
|
|
393
|
+
elif op == "$push":
|
|
394
|
+
for field, value in spec.items():
|
|
395
|
+
arr = _get_path(doc, field)
|
|
396
|
+
arr = list(arr) if isinstance(arr, list) else []
|
|
397
|
+
if isinstance(value, dict) and "$each" in value:
|
|
398
|
+
arr.extend(value["$each"])
|
|
399
|
+
else:
|
|
400
|
+
arr.append(value)
|
|
401
|
+
_set_path(doc, field, arr)
|
|
402
|
+
elif op == "$addToSet":
|
|
403
|
+
for field, value in spec.items():
|
|
404
|
+
arr = _get_path(doc, field)
|
|
405
|
+
arr = list(arr) if isinstance(arr, list) else []
|
|
406
|
+
items = value["$each"] if isinstance(value, dict) and "$each" in value else [value]
|
|
407
|
+
for it in items:
|
|
408
|
+
if it not in arr:
|
|
409
|
+
arr.append(it)
|
|
410
|
+
_set_path(doc, field, arr)
|
|
411
|
+
elif op == "$pull":
|
|
412
|
+
for field, cond in spec.items():
|
|
413
|
+
arr = _get_path(doc, field)
|
|
414
|
+
if not isinstance(arr, list):
|
|
415
|
+
continue
|
|
416
|
+
if _is_operator_doc(cond):
|
|
417
|
+
arr = [x for x in arr if not _match_field(x, cond)]
|
|
418
|
+
else:
|
|
419
|
+
arr = [x for x in arr if x != cond]
|
|
420
|
+
_set_path(doc, field, arr)
|
|
421
|
+
elif op == "$pop":
|
|
422
|
+
for field, direction in spec.items():
|
|
423
|
+
arr = _get_path(doc, field)
|
|
424
|
+
if isinstance(arr, list) and arr:
|
|
425
|
+
arr = list(arr)
|
|
426
|
+
arr.pop(0 if direction < 0 else -1)
|
|
427
|
+
_set_path(doc, field, arr)
|
|
428
|
+
else:
|
|
429
|
+
raise MongoUnsupportedError(f"Unsupported update operator {op!r}")
|
|
430
|
+
return doc
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ── Cursor ────────────────────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
class Cursor:
|
|
436
|
+
"""Lazy, chainable result of ``find()`` — mirrors pymongo's Cursor surface."""
|
|
437
|
+
|
|
438
|
+
def __init__(self, collection: "Collection", filt: dict, projection: Optional[dict]):
|
|
439
|
+
self._coll = collection
|
|
440
|
+
self._filt = filt or {}
|
|
441
|
+
self._proj = projection
|
|
442
|
+
self._sort: Optional[List[Tuple[str, int]]] = None
|
|
443
|
+
self._skip = 0
|
|
444
|
+
self._limit: Optional[int] = None
|
|
445
|
+
|
|
446
|
+
def sort(self, key_or_list: Union[str, List[Tuple[str, int]]],
|
|
447
|
+
direction: Optional[int] = None) -> "Cursor":
|
|
448
|
+
if isinstance(key_or_list, str):
|
|
449
|
+
self._sort = [(key_or_list, direction if direction is not None else 1)]
|
|
450
|
+
else:
|
|
451
|
+
self._sort = list(key_or_list)
|
|
452
|
+
return self
|
|
453
|
+
|
|
454
|
+
def skip(self, n: int) -> "Cursor":
|
|
455
|
+
self._skip = int(n)
|
|
456
|
+
return self
|
|
457
|
+
|
|
458
|
+
def limit(self, n: int) -> "Cursor":
|
|
459
|
+
self._limit = int(n)
|
|
460
|
+
return self
|
|
461
|
+
|
|
462
|
+
def _materialize(self) -> List[dict]:
|
|
463
|
+
docs = self._coll._find_docs(self._filt)
|
|
464
|
+
if self._sort:
|
|
465
|
+
for field, dirn in reversed(self._sort):
|
|
466
|
+
docs.sort(key=lambda d: _sort_key(_get_path(d, field)), reverse=(dirn < 0))
|
|
467
|
+
if self._skip:
|
|
468
|
+
docs = docs[self._skip:]
|
|
469
|
+
if self._limit is not None:
|
|
470
|
+
docs = docs[:self._limit]
|
|
471
|
+
if self._proj is not None:
|
|
472
|
+
docs = [_project(d, self._proj) for d in docs]
|
|
473
|
+
return docs
|
|
474
|
+
|
|
475
|
+
def to_list(self, length: Optional[int] = None) -> List[dict]:
|
|
476
|
+
docs = self._materialize()
|
|
477
|
+
return docs if length is None else docs[:length]
|
|
478
|
+
|
|
479
|
+
def __iter__(self):
|
|
480
|
+
return iter(self._materialize())
|
|
481
|
+
|
|
482
|
+
def __len__(self) -> int:
|
|
483
|
+
return len(self._materialize())
|
|
484
|
+
|
|
485
|
+
def count(self) -> int:
|
|
486
|
+
return len(self._materialize())
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ── Collection ─────────────────────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
class Collection:
|
|
492
|
+
"""A MongoDB-style collection backed by one NEDB collection."""
|
|
493
|
+
|
|
494
|
+
def __init__(self, db: Any, name: str, client: str = "mongo-compat"):
|
|
495
|
+
self._db = db
|
|
496
|
+
self._coll = name
|
|
497
|
+
self._client = client
|
|
498
|
+
|
|
499
|
+
@property
|
|
500
|
+
def name(self) -> str:
|
|
501
|
+
return self._coll
|
|
502
|
+
|
|
503
|
+
# ── candidate selection (index fast-paths, then guaranteed match) ──────────
|
|
504
|
+
def _candidates(self, filt: dict) -> List[dict]:
|
|
505
|
+
"""Return a SUPERSET of matching docs; _match() does the authoritative filter.
|
|
506
|
+
|
|
507
|
+
Narrowing is only delegated to the engine for fields that have an eq index.
|
|
508
|
+
Such fields are guaranteed scalar (NEDB can't eq-index an unhashable array),
|
|
509
|
+
so the engine's strict ``=`` is lossless there. Every other field — which may
|
|
510
|
+
hold arrays or be absent — is left to _match so MongoDB's array-membership and
|
|
511
|
+
null/missing semantics are preserved.
|
|
512
|
+
"""
|
|
513
|
+
idq = filt.get("_id") if isinstance(filt, dict) else None
|
|
514
|
+
if isinstance(idq, (str, int)) and not isinstance(idq, bool):
|
|
515
|
+
d = self._db.get(self._coll, str(idq))
|
|
516
|
+
return [d] if d is not None else []
|
|
517
|
+
if isinstance(idq, dict) and set(idq.keys()) == {"$in"}:
|
|
518
|
+
out = []
|
|
519
|
+
for v in idq["$in"]:
|
|
520
|
+
d = self._db.get(self._coll, str(v))
|
|
521
|
+
if d is not None:
|
|
522
|
+
out.append(d)
|
|
523
|
+
return out
|
|
524
|
+
|
|
525
|
+
where: List[Tuple[str, str, Any]] = []
|
|
526
|
+
if filt:
|
|
527
|
+
for k, v in filt.items():
|
|
528
|
+
if k.startswith("$") or "." in k:
|
|
529
|
+
where = [] # logical/dotted query → can't narrow safely; full scan
|
|
530
|
+
break
|
|
531
|
+
# only scalar equality on an eq-indexed (hence scalar) field is lossless
|
|
532
|
+
if (not isinstance(v, (dict, list))
|
|
533
|
+
and self._db.indexes.has_eq(self._coll, k)):
|
|
534
|
+
where.append((k, "=", v))
|
|
535
|
+
if where:
|
|
536
|
+
plan = empty_plan(self._coll)
|
|
537
|
+
plan["where"] = where
|
|
538
|
+
return self._db.execute(plan)
|
|
539
|
+
return self._db.query(f"FROM {self._coll}")
|
|
540
|
+
|
|
541
|
+
def _find_docs(self, filt: dict) -> List[dict]:
|
|
542
|
+
return [d for d in self._candidates(filt) if _match(d, filt or {})]
|
|
543
|
+
|
|
544
|
+
# ── inserts ────────────────────────────────────────────────────────────────
|
|
545
|
+
def insert_one(self, document: dict) -> InsertOneResult:
|
|
546
|
+
doc = dict(document)
|
|
547
|
+
_id = doc.get("_id")
|
|
548
|
+
if _id is None:
|
|
549
|
+
_id = ObjectId()
|
|
550
|
+
doc["_id"] = _id
|
|
551
|
+
if self._db.get(self._coll, str(_id)) is not None:
|
|
552
|
+
raise MongoError(f"E11000 duplicate key error: _id {_id!r} already exists")
|
|
553
|
+
self._db.put(self._coll, str(_id), doc, client=self._client)
|
|
554
|
+
return InsertOneResult(_id)
|
|
555
|
+
|
|
556
|
+
def insert_many(self, documents: Iterable[dict], ordered: bool = True) -> InsertManyResult:
|
|
557
|
+
ids: List[Any] = []
|
|
558
|
+
for document in documents:
|
|
559
|
+
ids.append(self.insert_one(document).inserted_id)
|
|
560
|
+
return InsertManyResult(ids)
|
|
561
|
+
|
|
562
|
+
# ── reads ────────────────────────────────────────────────────────────────────
|
|
563
|
+
def find(self, filter: Optional[dict] = None, projection: Optional[dict] = None) -> Cursor:
|
|
564
|
+
return Cursor(self, filter or {}, projection)
|
|
565
|
+
|
|
566
|
+
def find_one(self, filter: Optional[dict] = None,
|
|
567
|
+
projection: Optional[dict] = None) -> Optional[dict]:
|
|
568
|
+
docs = self.find(filter or {}, projection).limit(1).to_list()
|
|
569
|
+
return docs[0] if docs else None
|
|
570
|
+
|
|
571
|
+
def count_documents(self, filter: Optional[dict] = None) -> int:
|
|
572
|
+
return len(self._find_docs(filter or {}))
|
|
573
|
+
|
|
574
|
+
def estimated_document_count(self) -> int:
|
|
575
|
+
return len(self._db.query(f"FROM {self._coll}"))
|
|
576
|
+
|
|
577
|
+
def distinct(self, key: str, filter: Optional[dict] = None) -> List[Any]:
|
|
578
|
+
seen: List[Any] = []
|
|
579
|
+
for d in self._find_docs(filter or {}):
|
|
580
|
+
val = _get_path(d, key)
|
|
581
|
+
if val is _MISSING:
|
|
582
|
+
continue
|
|
583
|
+
vals = val if isinstance(val, list) else [val]
|
|
584
|
+
for v in vals:
|
|
585
|
+
if v not in seen:
|
|
586
|
+
seen.append(v)
|
|
587
|
+
return seen
|
|
588
|
+
|
|
589
|
+
# ── updates ────────────────────────────────────────────────────────────────
|
|
590
|
+
def _write(self, doc: dict, _id: Any) -> None:
|
|
591
|
+
doc = dict(doc)
|
|
592
|
+
doc["_id"] = _id
|
|
593
|
+
self._db.put(self._coll, str(_id), doc, client=self._client)
|
|
594
|
+
|
|
595
|
+
def update_one(self, filter: dict, update: dict, upsert: bool = False) -> UpdateResult:
|
|
596
|
+
return self._update(filter, update, upsert, many=False)
|
|
597
|
+
|
|
598
|
+
def update_many(self, filter: dict, update: dict, upsert: bool = False) -> UpdateResult:
|
|
599
|
+
return self._update(filter, update, upsert, many=True)
|
|
600
|
+
|
|
601
|
+
def _update(self, filter: dict, update: dict, upsert: bool, many: bool) -> UpdateResult:
|
|
602
|
+
matches = self._find_docs(filter or {})
|
|
603
|
+
if not matches and upsert:
|
|
604
|
+
seed: dict = {}
|
|
605
|
+
# seed equality fields from the filter
|
|
606
|
+
for k, v in (filter or {}).items():
|
|
607
|
+
if not k.startswith("$") and not _is_operator_doc(v) and not isinstance(v, (dict, list)):
|
|
608
|
+
seed[k] = v
|
|
609
|
+
new_doc = _apply_update(seed, update, on_insert=True)
|
|
610
|
+
_id = new_doc.get("_id") or filter.get("_id") or ObjectId()
|
|
611
|
+
new_doc["_id"] = _id
|
|
612
|
+
self._db.put(self._coll, str(_id), new_doc, client=self._client)
|
|
613
|
+
return UpdateResult(0, 0, upserted_id=_id)
|
|
614
|
+
|
|
615
|
+
targets = matches if many else matches[:1]
|
|
616
|
+
modified = 0
|
|
617
|
+
for doc in targets:
|
|
618
|
+
_id = doc.get("_id")
|
|
619
|
+
updated = _apply_update(doc, update)
|
|
620
|
+
updated["_id"] = _id # _id is immutable
|
|
621
|
+
if updated != doc:
|
|
622
|
+
self._write(updated, _id)
|
|
623
|
+
modified += 1
|
|
624
|
+
return UpdateResult(len(targets), modified)
|
|
625
|
+
|
|
626
|
+
def replace_one(self, filter: dict, replacement: dict, upsert: bool = False) -> UpdateResult:
|
|
627
|
+
if any(k.startswith("$") for k in replacement):
|
|
628
|
+
raise MongoError("replace_one requires a replacement document (no update operators).")
|
|
629
|
+
matches = self._find_docs(filter or {})
|
|
630
|
+
if not matches:
|
|
631
|
+
if upsert:
|
|
632
|
+
doc = dict(replacement)
|
|
633
|
+
_id = doc.get("_id") or filter.get("_id") or ObjectId()
|
|
634
|
+
doc["_id"] = _id
|
|
635
|
+
self._db.put(self._coll, str(_id), doc, client=self._client)
|
|
636
|
+
return UpdateResult(0, 0, upserted_id=_id)
|
|
637
|
+
return UpdateResult(0, 0)
|
|
638
|
+
target = matches[0]
|
|
639
|
+
_id = target.get("_id")
|
|
640
|
+
doc = dict(replacement)
|
|
641
|
+
doc["_id"] = _id
|
|
642
|
+
changed = doc != target
|
|
643
|
+
if changed:
|
|
644
|
+
self._write(doc, _id)
|
|
645
|
+
return UpdateResult(1, 1 if changed else 0)
|
|
646
|
+
|
|
647
|
+
# ── deletes ────────────────────────────────────────────────────────────────
|
|
648
|
+
def delete_one(self, filter: dict) -> DeleteResult:
|
|
649
|
+
matches = self._find_docs(filter or {})
|
|
650
|
+
if not matches:
|
|
651
|
+
return DeleteResult(0)
|
|
652
|
+
self._db.delete(self._coll, str(matches[0]["_id"]), client=self._client)
|
|
653
|
+
return DeleteResult(1)
|
|
654
|
+
|
|
655
|
+
def delete_many(self, filter: dict) -> DeleteResult:
|
|
656
|
+
matches = self._find_docs(filter or {})
|
|
657
|
+
for d in matches:
|
|
658
|
+
self._db.delete(self._coll, str(d["_id"]), client=self._client)
|
|
659
|
+
return DeleteResult(len(matches))
|
|
660
|
+
|
|
661
|
+
def drop(self) -> None:
|
|
662
|
+
for d in self._db.query(f"FROM {self._coll}"):
|
|
663
|
+
self._db.delete(self._coll, str(d["_id"]), client=self._client)
|
|
664
|
+
|
|
665
|
+
# ── indexes ──────────────────────────────────────────────────────────────────
|
|
666
|
+
def create_index(self, keys: Union[str, List[Tuple[str, int]]], **kwargs: Any) -> str:
|
|
667
|
+
"""Create a NEDB index. A Mongo 'text' index → NEDB 'search'; otherwise 'eq'.
|
|
668
|
+
|
|
669
|
+
Pass ``nedb_kind="ordered"`` for a range index on a single field.
|
|
670
|
+
"""
|
|
671
|
+
kind = kwargs.get("nedb_kind", "eq")
|
|
672
|
+
if isinstance(keys, str):
|
|
673
|
+
fields = [(keys, kwargs.get("nedb_kind", kind))]
|
|
674
|
+
else:
|
|
675
|
+
fields = []
|
|
676
|
+
for field, direction in keys:
|
|
677
|
+
k = "search" if direction == "text" else kwargs.get("nedb_kind", kind)
|
|
678
|
+
fields.append((field, k))
|
|
679
|
+
for field, k in fields:
|
|
680
|
+
self._db.create_index(self._coll, field, k)
|
|
681
|
+
return "_".join(f for f, _ in fields) + "_index"
|
|
682
|
+
|
|
683
|
+
# ── aggregation ──────────────────────────────────────────────────────────────
|
|
684
|
+
def aggregate(self, pipeline: List[dict]) -> List[dict]:
|
|
685
|
+
docs: List[dict] = self._db.query(f"FROM {self._coll}")
|
|
686
|
+
for stage in pipeline:
|
|
687
|
+
if len(stage) != 1:
|
|
688
|
+
raise MongoError(f"Each aggregation stage needs exactly one operator: {stage!r}")
|
|
689
|
+
(op, spec), = stage.items()
|
|
690
|
+
if op == "$match":
|
|
691
|
+
docs = [d for d in docs if _match(d, spec)]
|
|
692
|
+
elif op == "$sort":
|
|
693
|
+
for field, dirn in reversed(list(spec.items())):
|
|
694
|
+
docs.sort(key=lambda d: _sort_key(_get_path(d, field)), reverse=(dirn < 0))
|
|
695
|
+
elif op == "$skip":
|
|
696
|
+
docs = docs[spec:]
|
|
697
|
+
elif op == "$limit":
|
|
698
|
+
docs = docs[:spec]
|
|
699
|
+
elif op == "$count":
|
|
700
|
+
docs = [{spec: len(docs)}]
|
|
701
|
+
elif op == "$project":
|
|
702
|
+
docs = [_project(d, spec) for d in docs]
|
|
703
|
+
elif op == "$group":
|
|
704
|
+
docs = _group(docs, spec)
|
|
705
|
+
else:
|
|
706
|
+
raise MongoUnsupportedError(
|
|
707
|
+
f"Aggregation stage {op!r} is not yet supported. "
|
|
708
|
+
f"Supported: $match $group $sort $skip $limit $count $project."
|
|
709
|
+
)
|
|
710
|
+
return docs
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _agg_val(doc: dict, expr: Any) -> Any:
|
|
714
|
+
if isinstance(expr, str) and expr.startswith("$"):
|
|
715
|
+
v = _get_path(doc, expr[1:])
|
|
716
|
+
return None if v is _MISSING else v
|
|
717
|
+
return expr
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _group(docs: List[dict], spec: dict) -> List[dict]:
|
|
721
|
+
id_expr = spec.get("_id")
|
|
722
|
+
|
|
723
|
+
def key_of(d: dict) -> Any:
|
|
724
|
+
if id_expr is None:
|
|
725
|
+
return None
|
|
726
|
+
if isinstance(id_expr, str) and id_expr.startswith("$"):
|
|
727
|
+
v = _get_path(d, id_expr[1:])
|
|
728
|
+
return None if v is _MISSING else v
|
|
729
|
+
if isinstance(id_expr, dict):
|
|
730
|
+
return {k: _agg_val(d, e) for k, e in id_expr.items()}
|
|
731
|
+
return id_expr
|
|
732
|
+
|
|
733
|
+
groups: Dict[str, List[dict]] = {}
|
|
734
|
+
order: List[Tuple[str, Any]] = []
|
|
735
|
+
for d in docs:
|
|
736
|
+
k = key_of(d)
|
|
737
|
+
hk = json.dumps(k, sort_keys=True, default=str)
|
|
738
|
+
if hk not in groups:
|
|
739
|
+
groups[hk] = []
|
|
740
|
+
order.append((hk, k))
|
|
741
|
+
groups[hk].append(d)
|
|
742
|
+
|
|
743
|
+
out: List[dict] = []
|
|
744
|
+
for hk, k in order:
|
|
745
|
+
g = groups[hk]
|
|
746
|
+
entry: dict = {"_id": k}
|
|
747
|
+
for field, acc in spec.items():
|
|
748
|
+
if field == "_id":
|
|
749
|
+
continue
|
|
750
|
+
if not isinstance(acc, dict) or len(acc) != 1:
|
|
751
|
+
raise MongoError(f"Invalid accumulator for {field!r}: {acc!r}")
|
|
752
|
+
(afn, aexpr), = acc.items()
|
|
753
|
+
vals = [_agg_val(d, aexpr) for d in g]
|
|
754
|
+
nums = [v for v in vals if isinstance(v, (int, float)) and not isinstance(v, bool)]
|
|
755
|
+
if afn == "$sum":
|
|
756
|
+
entry[field] = len(g) if aexpr in (1, "1") else sum(nums)
|
|
757
|
+
elif afn == "$avg":
|
|
758
|
+
entry[field] = (sum(nums) / len(nums)) if nums else None
|
|
759
|
+
elif afn == "$min":
|
|
760
|
+
entry[field] = min(nums) if nums else None
|
|
761
|
+
elif afn == "$max":
|
|
762
|
+
entry[field] = max(nums) if nums else None
|
|
763
|
+
elif afn == "$first":
|
|
764
|
+
entry[field] = vals[0] if vals else None
|
|
765
|
+
elif afn == "$last":
|
|
766
|
+
entry[field] = vals[-1] if vals else None
|
|
767
|
+
elif afn == "$push":
|
|
768
|
+
entry[field] = vals
|
|
769
|
+
elif afn == "$addToSet":
|
|
770
|
+
uniq: List[Any] = []
|
|
771
|
+
for v in vals:
|
|
772
|
+
if v not in uniq:
|
|
773
|
+
uniq.append(v)
|
|
774
|
+
entry[field] = uniq
|
|
775
|
+
else:
|
|
776
|
+
raise MongoUnsupportedError(f"Accumulator {afn!r} is not supported.")
|
|
777
|
+
out.append(entry)
|
|
778
|
+
return out
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# ── Client ──────────────────────────────────────────────────────────────────────────
|
|
782
|
+
|
|
783
|
+
class MongoCompat:
|
|
784
|
+
"""
|
|
785
|
+
MongoDB-compatible client over a NEDB database.
|
|
786
|
+
|
|
787
|
+
NEDB is a single logical database, so collections are reached directly::
|
|
788
|
+
|
|
789
|
+
mongo = MongoCompat(db)
|
|
790
|
+
mongo["users"] # item access
|
|
791
|
+
mongo.collection("users") # explicit
|
|
792
|
+
mongo.db["users"] # pymongo-style db handle (mongo.db is self)
|
|
793
|
+
|
|
794
|
+
Every write goes through NEDB's replay-protected, hash-chained log; pass
|
|
795
|
+
``client`` to scope nonce counters per service.
|
|
796
|
+
"""
|
|
797
|
+
|
|
798
|
+
def __init__(self, db: Any, client: str = "mongo-compat"):
|
|
799
|
+
self._db = db
|
|
800
|
+
self._client = client
|
|
801
|
+
|
|
802
|
+
def collection(self, name: str) -> Collection:
|
|
803
|
+
return Collection(self._db, name, self._client)
|
|
804
|
+
|
|
805
|
+
def __getitem__(self, name: str) -> Collection:
|
|
806
|
+
return self.collection(name)
|
|
807
|
+
|
|
808
|
+
@property
|
|
809
|
+
def db(self) -> "MongoCompat":
|
|
810
|
+
return self
|
|
811
|
+
|
|
812
|
+
def list_collection_names(self) -> List[str]:
|
|
813
|
+
names = set()
|
|
814
|
+
for key in self._db.store.keys():
|
|
815
|
+
if ":" in key:
|
|
816
|
+
names.add(key.split(":", 1)[0])
|
|
817
|
+
return sorted(names)
|
|
818
|
+
|
|
819
|
+
def drop_collection(self, name: str) -> None:
|
|
820
|
+
self.collection(name).drop()
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# pymongo users reach for MongoClient — provide it as an alias.
|
|
824
|
+
MongoClient = MongoCompat
|