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/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