pymingledb 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pymingledb
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Lightweight file-based NoSQL engine (Python port of mingleDB). BSON + zlib, schema, query operators, auth.
|
|
5
|
+
Author: Mark Wayne Menorca
|
|
6
|
+
Author-email: Mark Wayne Menorca <marcuwynu23@gmail.com>
|
|
7
|
+
Requires-Dist: pymongo>=4.0
|
|
8
|
+
Requires-Dist: pytest>=7.0 ; extra == 'dev'
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# pymingleDB
|
|
14
|
+
|
|
15
|
+
Lightweight file-based NoSQL engine — Python port of [mingleDB](https://github.com/marcuwynu23/mingleDB). Same format as the JavaScript and [gomingleDB](https://github.com/marcuwynu23/gomingleDB) implementations: BSON serialization, zlib compression, optional schema validation, query operators, and basic authentication.
|
|
16
|
+
|
|
17
|
+
## Install (uv)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd pymingleDB
|
|
21
|
+
uv sync
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or add to your project:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add pymingleDB
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from pymingledb import MingleDB, ValidationError, UsernameExistsError, AuthFailedError
|
|
34
|
+
|
|
35
|
+
db = MingleDB("./mydb") # or any path
|
|
36
|
+
|
|
37
|
+
# Schema (optional)
|
|
38
|
+
db.define_schema("users", {
|
|
39
|
+
"name": {"type": "string", "required": True},
|
|
40
|
+
"email": {"type": "string", "required": True, "unique": True},
|
|
41
|
+
"age": {"type": "number"},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
# CRUD
|
|
45
|
+
db.insert_one("users", {"name": "Alice", "email": "alice@example.com", "age": 30})
|
|
46
|
+
db.insert_one("users", {"name": "Bob", "email": "bob@example.com", "age": 17})
|
|
47
|
+
|
|
48
|
+
db.find_all("users")
|
|
49
|
+
db.find_one("users", {"email": "alice@example.com"})
|
|
50
|
+
db.find("users", {"age": {"$gte": 18, "$lt": 60}})
|
|
51
|
+
db.find("users", {"name": {"$regex": "ali", "$options": "i"}})
|
|
52
|
+
db.find("users", {"email": {"$in": ["alice@example.com", "bob@example.com"]}})
|
|
53
|
+
|
|
54
|
+
db.update_one("users", {"name": "Alice"}, {"age": 31})
|
|
55
|
+
db.delete_one("users", {"email": "bob@example.com"})
|
|
56
|
+
|
|
57
|
+
# Auth (uses internal _auth collection)
|
|
58
|
+
db.register_user("admin", "secure123")
|
|
59
|
+
db.login("admin", "secure123")
|
|
60
|
+
db.is_authenticated("admin") # True
|
|
61
|
+
db.logout("admin")
|
|
62
|
+
|
|
63
|
+
# Reset (wipe all collections and schemas)
|
|
64
|
+
db.reset()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Query operators
|
|
68
|
+
|
|
69
|
+
- `$gt`, `$gte`, `$lt`, `$lte` — numeric comparison
|
|
70
|
+
- `$eq`, `$ne` — equality
|
|
71
|
+
- `$in`, `$nin` — in list / not in list
|
|
72
|
+
- `$regex`, `$options` — regex (e.g. `"i"` for case-insensitive)
|
|
73
|
+
|
|
74
|
+
You can also pass a compiled `re.Pattern` as a filter value for regex match.
|
|
75
|
+
|
|
76
|
+
## Exceptions
|
|
77
|
+
|
|
78
|
+
- `MingleDBError` — base
|
|
79
|
+
- `UsernameExistsError` — register with existing username
|
|
80
|
+
- `AuthFailedError` — login failed
|
|
81
|
+
- `ValidationError` — schema validation (required, type, unique)
|
|
82
|
+
|
|
83
|
+
## File format
|
|
84
|
+
|
|
85
|
+
Collections are stored as `<collection>.mgdb` files: header `MINGLEDBv1`, 4-byte meta length, JSON meta, then for each document: 4-byte length + zlib(BSON(doc)). Compatible with mingleDB (JS) and gomingleDB (Go).
|
|
86
|
+
|
|
87
|
+
## Tests
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv sync --extra dev
|
|
91
|
+
uv run pytest tests/ -v
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Use under the same terms as mingleDB / gomingleDB.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# pymingleDB
|
|
2
|
+
|
|
3
|
+
Lightweight file-based NoSQL engine — Python port of [mingleDB](https://github.com/marcuwynu23/mingleDB). Same format as the JavaScript and [gomingleDB](https://github.com/marcuwynu23/gomingleDB) implementations: BSON serialization, zlib compression, optional schema validation, query operators, and basic authentication.
|
|
4
|
+
|
|
5
|
+
## Install (uv)
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd pymingleDB
|
|
9
|
+
uv sync
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or add to your project:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv add pymingleDB
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pymingledb import MingleDB, ValidationError, UsernameExistsError, AuthFailedError
|
|
22
|
+
|
|
23
|
+
db = MingleDB("./mydb") # or any path
|
|
24
|
+
|
|
25
|
+
# Schema (optional)
|
|
26
|
+
db.define_schema("users", {
|
|
27
|
+
"name": {"type": "string", "required": True},
|
|
28
|
+
"email": {"type": "string", "required": True, "unique": True},
|
|
29
|
+
"age": {"type": "number"},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
# CRUD
|
|
33
|
+
db.insert_one("users", {"name": "Alice", "email": "alice@example.com", "age": 30})
|
|
34
|
+
db.insert_one("users", {"name": "Bob", "email": "bob@example.com", "age": 17})
|
|
35
|
+
|
|
36
|
+
db.find_all("users")
|
|
37
|
+
db.find_one("users", {"email": "alice@example.com"})
|
|
38
|
+
db.find("users", {"age": {"$gte": 18, "$lt": 60}})
|
|
39
|
+
db.find("users", {"name": {"$regex": "ali", "$options": "i"}})
|
|
40
|
+
db.find("users", {"email": {"$in": ["alice@example.com", "bob@example.com"]}})
|
|
41
|
+
|
|
42
|
+
db.update_one("users", {"name": "Alice"}, {"age": 31})
|
|
43
|
+
db.delete_one("users", {"email": "bob@example.com"})
|
|
44
|
+
|
|
45
|
+
# Auth (uses internal _auth collection)
|
|
46
|
+
db.register_user("admin", "secure123")
|
|
47
|
+
db.login("admin", "secure123")
|
|
48
|
+
db.is_authenticated("admin") # True
|
|
49
|
+
db.logout("admin")
|
|
50
|
+
|
|
51
|
+
# Reset (wipe all collections and schemas)
|
|
52
|
+
db.reset()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Query operators
|
|
56
|
+
|
|
57
|
+
- `$gt`, `$gte`, `$lt`, `$lte` — numeric comparison
|
|
58
|
+
- `$eq`, `$ne` — equality
|
|
59
|
+
- `$in`, `$nin` — in list / not in list
|
|
60
|
+
- `$regex`, `$options` — regex (e.g. `"i"` for case-insensitive)
|
|
61
|
+
|
|
62
|
+
You can also pass a compiled `re.Pattern` as a filter value for regex match.
|
|
63
|
+
|
|
64
|
+
## Exceptions
|
|
65
|
+
|
|
66
|
+
- `MingleDBError` — base
|
|
67
|
+
- `UsernameExistsError` — register with existing username
|
|
68
|
+
- `AuthFailedError` — login failed
|
|
69
|
+
- `ValidationError` — schema validation (required, type, unique)
|
|
70
|
+
|
|
71
|
+
## File format
|
|
72
|
+
|
|
73
|
+
Collections are stored as `<collection>.mgdb` files: header `MINGLEDBv1`, 4-byte meta length, JSON meta, then for each document: 4-byte length + zlib(BSON(doc)). Compatible with mingleDB (JS) and gomingleDB (Go).
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv sync --extra dev
|
|
79
|
+
uv run pytest tests/ -v
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
Use under the same terms as mingleDB / gomingleDB.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pymingledb"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Lightweight file-based NoSQL engine (Python port of mingleDB). BSON + zlib, schema, query operators, auth."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Mark Wayne Menorca", email = "marcuwynu23@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pymongo>=4.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=7.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
testpaths = ["tests"]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.8.22,<0.9.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""pymingleDB - Lightweight file-based NoSQL engine (Python port of mingleDB)."""
|
|
2
|
+
|
|
3
|
+
from pymingledb.mingle import (
|
|
4
|
+
MingleDB,
|
|
5
|
+
MingleDBError,
|
|
6
|
+
AuthFailedError,
|
|
7
|
+
UsernameExistsError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"MingleDB",
|
|
13
|
+
"MingleDBError",
|
|
14
|
+
"AuthFailedError",
|
|
15
|
+
"UsernameExistsError",
|
|
16
|
+
"ValidationError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# pymingleDB - Lightweight file-based NoSQL engine (Python port of mingleDB).
|
|
2
|
+
# BSON serialization, zlib compression, schema validation, query operators, basic auth.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import struct
|
|
10
|
+
import threading
|
|
11
|
+
import zlib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Pattern
|
|
14
|
+
|
|
15
|
+
from bson import BSON
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
HEADER = b"MINGLEDBv1"
|
|
19
|
+
EXTENSION = ".mgdb"
|
|
20
|
+
AUTH_COLLECTION = "_auth"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MingleDBError(Exception):
|
|
24
|
+
"""Base exception for pymingleDB."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UsernameExistsError(MingleDBError):
|
|
28
|
+
"""Username already exists when registering."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthFailedError(MingleDBError):
|
|
32
|
+
"""Authentication failed (wrong username or password)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ValidationError(MingleDBError):
|
|
36
|
+
"""Schema validation error (required, type, unique)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _float_cmp(a: Any, b: Any) -> tuple[float, float] | None:
|
|
40
|
+
"""Return (a, b) as floats if both are numeric, else None."""
|
|
41
|
+
try:
|
|
42
|
+
fa = float(a) if isinstance(a, (int, float)) else None
|
|
43
|
+
fb = float(b) if isinstance(b, (int, float)) else None
|
|
44
|
+
if fa is not None and fb is not None:
|
|
45
|
+
return (fa, fb)
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
pass
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _value_equal(a: Any, b: Any) -> bool:
|
|
52
|
+
if a is None and b is None:
|
|
53
|
+
return True
|
|
54
|
+
if a is None or b is None:
|
|
55
|
+
return False
|
|
56
|
+
pair = _float_cmp(a, b)
|
|
57
|
+
if pair is not None:
|
|
58
|
+
return pair[0] == pair[1]
|
|
59
|
+
return a == b
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _match_operators(doc_val: Any, op_map: dict[str, Any]) -> bool:
|
|
63
|
+
for op, op_val in op_map.items():
|
|
64
|
+
if op == "$options":
|
|
65
|
+
continue
|
|
66
|
+
if op == "$gt":
|
|
67
|
+
pair = _float_cmp(doc_val, op_val)
|
|
68
|
+
if pair is None or not (pair[0] > pair[1]):
|
|
69
|
+
return False
|
|
70
|
+
elif op == "$gte":
|
|
71
|
+
pair = _float_cmp(doc_val, op_val)
|
|
72
|
+
if pair is None or not (pair[0] >= pair[1]):
|
|
73
|
+
return False
|
|
74
|
+
elif op == "$lt":
|
|
75
|
+
pair = _float_cmp(doc_val, op_val)
|
|
76
|
+
if pair is None or not (pair[0] < pair[1]):
|
|
77
|
+
return False
|
|
78
|
+
elif op == "$lte":
|
|
79
|
+
pair = _float_cmp(doc_val, op_val)
|
|
80
|
+
if pair is None or not (pair[0] <= pair[1]):
|
|
81
|
+
return False
|
|
82
|
+
elif op == "$eq":
|
|
83
|
+
if not _value_equal(doc_val, op_val):
|
|
84
|
+
return False
|
|
85
|
+
elif op == "$ne":
|
|
86
|
+
if _value_equal(doc_val, op_val):
|
|
87
|
+
return False
|
|
88
|
+
elif op == "$in":
|
|
89
|
+
if not isinstance(op_val, (list, tuple)):
|
|
90
|
+
return False
|
|
91
|
+
if not any(_value_equal(doc_val, v) for v in op_val):
|
|
92
|
+
return False
|
|
93
|
+
elif op == "$nin":
|
|
94
|
+
if isinstance(op_val, (list, tuple)) and any(_value_equal(doc_val, v) for v in op_val):
|
|
95
|
+
return False
|
|
96
|
+
elif op == "$regex":
|
|
97
|
+
pattern = op_val if isinstance(op_val, str) else ""
|
|
98
|
+
flags = 0
|
|
99
|
+
if isinstance(op_map.get("$options"), str) and "i" in op_map["$options"]:
|
|
100
|
+
flags = re.IGNORECASE
|
|
101
|
+
try:
|
|
102
|
+
rx = re.compile(pattern, flags)
|
|
103
|
+
except re.error:
|
|
104
|
+
return False
|
|
105
|
+
if not isinstance(doc_val, str) or not rx.search(doc_val):
|
|
106
|
+
return False
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _match_query(doc: dict[str, Any], filter: dict[str, Any]) -> bool:
|
|
111
|
+
for key, filter_val in filter.items():
|
|
112
|
+
doc_val = doc.get(key)
|
|
113
|
+
if filter_val is None:
|
|
114
|
+
if doc_val is not None:
|
|
115
|
+
return False
|
|
116
|
+
continue
|
|
117
|
+
if isinstance(filter_val, dict) and any(
|
|
118
|
+
k.startswith("$") for k in filter_val
|
|
119
|
+
):
|
|
120
|
+
if not _match_operators(doc_val, filter_val):
|
|
121
|
+
return False
|
|
122
|
+
continue
|
|
123
|
+
if isinstance(filter_val, Pattern):
|
|
124
|
+
if not isinstance(doc_val, str) or not filter_val.search(doc_val):
|
|
125
|
+
return False
|
|
126
|
+
continue
|
|
127
|
+
if not _value_equal(doc_val, filter_val):
|
|
128
|
+
return False
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MingleDB:
|
|
133
|
+
"""
|
|
134
|
+
Lightweight file-based NoSQL database.
|
|
135
|
+
Uses BSON + zlib, optional schema validation, query operators ($gt, $gte, $in, $regex, etc.),
|
|
136
|
+
and basic authentication (register_user / login / logout).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, db_dir: str | Path = ".mgdb") -> None:
|
|
140
|
+
self._db_dir = Path(db_dir)
|
|
141
|
+
self._db_dir.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
self._schemas: dict[str, dict[str, Any]] = {}
|
|
143
|
+
self._sessions: set[str] = set()
|
|
144
|
+
self._lock = threading.RLock()
|
|
145
|
+
|
|
146
|
+
def reset(self) -> None:
|
|
147
|
+
"""Remove all .mgdb collection files and clear schemas and auth state."""
|
|
148
|
+
with self._lock:
|
|
149
|
+
if self._db_dir.exists():
|
|
150
|
+
for f in self._db_dir.iterdir():
|
|
151
|
+
if f.is_file() and f.suffix == EXTENSION:
|
|
152
|
+
f.unlink()
|
|
153
|
+
self._schemas.clear()
|
|
154
|
+
self._sessions.clear()
|
|
155
|
+
|
|
156
|
+
def _get_file_path(self, collection: str) -> Path:
|
|
157
|
+
return self._db_dir / f"{collection}{EXTENSION}"
|
|
158
|
+
|
|
159
|
+
def _init_collection_file(self, collection: str) -> None:
|
|
160
|
+
path = self._get_file_path(collection)
|
|
161
|
+
if path.exists():
|
|
162
|
+
return
|
|
163
|
+
meta = json.dumps({"collection": collection}).encode("utf-8")
|
|
164
|
+
meta_len = struct.pack("<I", len(meta))
|
|
165
|
+
path.write_bytes(HEADER + meta_len + meta)
|
|
166
|
+
|
|
167
|
+
def _hash_password(self, password: str) -> str:
|
|
168
|
+
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
|
169
|
+
|
|
170
|
+
def define_schema(self, collection: str, schema_definition: dict[str, Any]) -> None:
|
|
171
|
+
"""Define schema for a collection. Rules: type ('string'|'number'), required, unique."""
|
|
172
|
+
with self._lock:
|
|
173
|
+
self._schemas[collection] = schema_definition
|
|
174
|
+
|
|
175
|
+
def _validate_schema(self, collection: str, doc: dict[str, Any]) -> None:
|
|
176
|
+
schema = self._schemas.get(collection)
|
|
177
|
+
if not schema:
|
|
178
|
+
return
|
|
179
|
+
all_docs = self._find_all_locked(collection)
|
|
180
|
+
for key, rule in schema.items():
|
|
181
|
+
if not isinstance(rule, dict):
|
|
182
|
+
continue
|
|
183
|
+
typ = rule.get("type")
|
|
184
|
+
required = rule.get("required", False)
|
|
185
|
+
unique = rule.get("unique", False)
|
|
186
|
+
value = doc.get(key)
|
|
187
|
+
if required and (value is None and key not in doc):
|
|
188
|
+
raise ValidationError(f'Field "{key}" is required.')
|
|
189
|
+
if value is not None and key in doc:
|
|
190
|
+
if typ == "string" and not isinstance(value, str):
|
|
191
|
+
raise ValidationError(f'Field "{key}" must be of type string.')
|
|
192
|
+
if typ == "number" and not isinstance(value, (int, float)):
|
|
193
|
+
raise ValidationError(f'Field "{key}" must be of type number.')
|
|
194
|
+
if unique:
|
|
195
|
+
for d in all_docs:
|
|
196
|
+
if d.get(key) == value:
|
|
197
|
+
raise ValidationError(
|
|
198
|
+
f'Duplicate value for unique field "{key}".'
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def register_user(self, username: str, password: str) -> None:
|
|
202
|
+
"""Register a user in _auth. Raises UsernameExistsError if username exists."""
|
|
203
|
+
with self._lock:
|
|
204
|
+
self._init_collection_file(AUTH_COLLECTION)
|
|
205
|
+
users = self._find_all_locked(AUTH_COLLECTION)
|
|
206
|
+
for u in users:
|
|
207
|
+
if u.get("username") == username:
|
|
208
|
+
raise UsernameExistsError("Username already exists.")
|
|
209
|
+
hashed = self._hash_password(password)
|
|
210
|
+
self._insert_one_locked(
|
|
211
|
+
AUTH_COLLECTION, {"username": username, "password": hashed}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def login(self, username: str, password: str) -> None:
|
|
215
|
+
"""Authenticate user; adds to session. Raises AuthFailedError on failure."""
|
|
216
|
+
with self._lock:
|
|
217
|
+
users = self._find_all_locked(AUTH_COLLECTION)
|
|
218
|
+
user = None
|
|
219
|
+
for u in users:
|
|
220
|
+
if u.get("username") == username:
|
|
221
|
+
user = u
|
|
222
|
+
break
|
|
223
|
+
if user is None or user.get("password") != self._hash_password(password):
|
|
224
|
+
raise AuthFailedError("Authentication failed.")
|
|
225
|
+
self._sessions.add(username)
|
|
226
|
+
|
|
227
|
+
def is_authenticated(self, username: str) -> bool:
|
|
228
|
+
return username in self._sessions
|
|
229
|
+
|
|
230
|
+
def logout(self, username: str) -> None:
|
|
231
|
+
self._sessions.discard(username)
|
|
232
|
+
|
|
233
|
+
def _insert_one_locked(self, collection: str, doc: dict[str, Any]) -> None:
|
|
234
|
+
raw = BSON.encode(doc)
|
|
235
|
+
compressed = zlib.compress(raw)
|
|
236
|
+
length = struct.pack("<I", len(compressed))
|
|
237
|
+
path = self._get_file_path(collection)
|
|
238
|
+
with open(path, "ab") as f:
|
|
239
|
+
f.write(length + compressed)
|
|
240
|
+
|
|
241
|
+
def insert_one(self, collection: str, doc: dict[str, Any]) -> None:
|
|
242
|
+
"""Append one document. Validates schema if defined."""
|
|
243
|
+
with self._lock:
|
|
244
|
+
self._init_collection_file(collection)
|
|
245
|
+
self._validate_schema(collection, doc)
|
|
246
|
+
self._insert_one_locked(collection, doc)
|
|
247
|
+
|
|
248
|
+
def _find_all_locked(self, collection: str) -> list[dict[str, Any]]:
|
|
249
|
+
path = self._get_file_path(collection)
|
|
250
|
+
if not path.exists():
|
|
251
|
+
return []
|
|
252
|
+
data = path.read_bytes()
|
|
253
|
+
if len(data) < len(HEADER) + 4:
|
|
254
|
+
return []
|
|
255
|
+
if data[: len(HEADER)] != HEADER:
|
|
256
|
+
raise MingleDBError("Invalid mingleDB file header.")
|
|
257
|
+
offset = len(HEADER)
|
|
258
|
+
(meta_len,) = struct.unpack("<I", data[offset : offset + 4])
|
|
259
|
+
offset += 4 + meta_len
|
|
260
|
+
docs = []
|
|
261
|
+
while offset + 4 <= len(data):
|
|
262
|
+
(doc_len,) = struct.unpack("<I", data[offset : offset + 4])
|
|
263
|
+
offset += 4
|
|
264
|
+
if offset + doc_len > len(data):
|
|
265
|
+
break
|
|
266
|
+
compressed = data[offset : offset + doc_len]
|
|
267
|
+
offset += doc_len
|
|
268
|
+
raw = zlib.decompress(compressed)
|
|
269
|
+
doc = BSON.decode(raw)
|
|
270
|
+
docs.append(doc)
|
|
271
|
+
return docs
|
|
272
|
+
|
|
273
|
+
def find_all(self, collection: str) -> list[dict[str, Any]]:
|
|
274
|
+
"""Return all documents in the collection."""
|
|
275
|
+
with self._lock:
|
|
276
|
+
return self._find_all_locked(collection)
|
|
277
|
+
|
|
278
|
+
def find(
|
|
279
|
+
self,
|
|
280
|
+
collection: str,
|
|
281
|
+
filter: dict[str, Any] | None = None,
|
|
282
|
+
) -> list[dict[str, Any]]:
|
|
283
|
+
"""Return documents matching the filter. Empty filter matches all."""
|
|
284
|
+
filter = filter or {}
|
|
285
|
+
with self._lock:
|
|
286
|
+
docs = self._find_all_locked(collection)
|
|
287
|
+
return [d for d in docs if _match_query(d, filter)]
|
|
288
|
+
|
|
289
|
+
def find_one(
|
|
290
|
+
self,
|
|
291
|
+
collection: str,
|
|
292
|
+
filter: dict[str, Any] | None = None,
|
|
293
|
+
) -> dict[str, Any] | None:
|
|
294
|
+
"""Return the first document matching the filter, or None."""
|
|
295
|
+
found = self.find(collection, filter or {})
|
|
296
|
+
return found[0] if found else None
|
|
297
|
+
|
|
298
|
+
def _rewrite_collection_locked(
|
|
299
|
+
self, collection: str, docs: list[dict[str, Any]]
|
|
300
|
+
) -> None:
|
|
301
|
+
meta = json.dumps({"collection": collection}).encode("utf-8")
|
|
302
|
+
meta_len = struct.pack("<I", len(meta))
|
|
303
|
+
body = bytearray()
|
|
304
|
+
for doc in docs:
|
|
305
|
+
raw = BSON.encode(doc)
|
|
306
|
+
compressed = zlib.compress(raw)
|
|
307
|
+
body.extend(struct.pack("<I", len(compressed)))
|
|
308
|
+
body.extend(compressed)
|
|
309
|
+
path = self._get_file_path(collection)
|
|
310
|
+
path.write_bytes(HEADER + meta_len + meta + bytes(body))
|
|
311
|
+
|
|
312
|
+
def update_one(
|
|
313
|
+
self,
|
|
314
|
+
collection: str,
|
|
315
|
+
query: dict[str, Any],
|
|
316
|
+
update: dict[str, Any],
|
|
317
|
+
) -> bool:
|
|
318
|
+
"""Update the first document matching query with update. Returns True if one was updated."""
|
|
319
|
+
with self._lock:
|
|
320
|
+
docs = self._find_all_locked(collection)
|
|
321
|
+
updated = False
|
|
322
|
+
for i, doc in enumerate(docs):
|
|
323
|
+
if not updated and _match_query(doc, query):
|
|
324
|
+
updated = True
|
|
325
|
+
docs[i] = {**doc, **update}
|
|
326
|
+
if updated:
|
|
327
|
+
self._rewrite_collection_locked(collection, docs)
|
|
328
|
+
return updated
|
|
329
|
+
|
|
330
|
+
def delete_one(self, collection: str, query: dict[str, Any]) -> bool:
|
|
331
|
+
"""Remove the first document matching query. Returns True if one was deleted."""
|
|
332
|
+
with self._lock:
|
|
333
|
+
docs = self._find_all_locked(collection)
|
|
334
|
+
out = []
|
|
335
|
+
deleted = False
|
|
336
|
+
for doc in docs:
|
|
337
|
+
if not deleted and _match_query(doc, query):
|
|
338
|
+
deleted = True
|
|
339
|
+
continue
|
|
340
|
+
out.append(doc)
|
|
341
|
+
if deleted:
|
|
342
|
+
self._rewrite_collection_locked(collection, out)
|
|
343
|
+
return deleted
|
|
File without changes
|