minidb-engine 0.1.0__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.
- minidb/__init__.py +4 -0
- minidb/database.py +391 -0
- minidb/executor.py +214 -0
- minidb/index.py +36 -0
- minidb/plugins/__init__.py +6 -0
- minidb/plugins/manager.py +42 -0
- minidb/plugins/query_logger.py +37 -0
- minidb/plugins/query_stats.py +53 -0
- minidb/query.py +284 -0
- minidb/storage.py +71 -0
- minidb_engine-0.1.0.dist-info/METADATA +310 -0
- minidb_engine-0.1.0.dist-info/RECORD +14 -0
- minidb_engine-0.1.0.dist-info/WHEEL +5 -0
- minidb_engine-0.1.0.dist-info/top_level.txt +1 -0
minidb/__init__.py
ADDED
minidb/database.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
|
|
2
|
+
"""Public MiniDB API with storage, indexing, schema validation and transactions."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from threading import RLock
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from time import perf_counter
|
|
11
|
+
|
|
12
|
+
from .index import Index
|
|
13
|
+
from .executor import execute_query
|
|
14
|
+
from .storage import StorageEngine
|
|
15
|
+
from .plugins import PluginManager
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def synchronized(method):
|
|
19
|
+
"""Serialize database operations using a re-entrant lock."""
|
|
20
|
+
@wraps(method)
|
|
21
|
+
def wrapper(self, *args, **kwargs):
|
|
22
|
+
with self._lock:
|
|
23
|
+
return method(self, *args, **kwargs)
|
|
24
|
+
return wrapper
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DatabaseError(Exception):
|
|
28
|
+
"""Base exception for database errors."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RecordNotFoundError(DatabaseError):
|
|
32
|
+
"""Raised when a requested record does not exist."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Database:
|
|
36
|
+
|
|
37
|
+
def __init__(self, file_path: str | Path = "data/minidb.log"):
|
|
38
|
+
self.storage = StorageEngine(file_path)
|
|
39
|
+
self._lock = RLock()
|
|
40
|
+
|
|
41
|
+
self.records: dict[str, dict[str, Any]] = {}
|
|
42
|
+
self.indexes = {}
|
|
43
|
+
self.schema = None
|
|
44
|
+
self.plugins = PluginManager()
|
|
45
|
+
|
|
46
|
+
self._transaction_snapshot = None
|
|
47
|
+
self._transaction_operations = None
|
|
48
|
+
|
|
49
|
+
self._recover()
|
|
50
|
+
|
|
51
|
+
def register_plugin(self, name: str, plugin: Any) -> None:
|
|
52
|
+
"""Register a plugin with this database."""
|
|
53
|
+
self.plugins.register(name, plugin)
|
|
54
|
+
|
|
55
|
+
def activate_plugin(self, name: str) -> None:
|
|
56
|
+
"""Activate a registered plugin for this database."""
|
|
57
|
+
self.plugins.activate(name, self)
|
|
58
|
+
|
|
59
|
+
def list_plugins(self) -> list[str]:
|
|
60
|
+
"""List all registered plugins."""
|
|
61
|
+
return self.plugins.list_plugins()
|
|
62
|
+
|
|
63
|
+
def _recover(self):
|
|
64
|
+
"""Rebuild records by replaying committed operations."""
|
|
65
|
+
|
|
66
|
+
def apply_operation(operation):
|
|
67
|
+
action = operation.get("action")
|
|
68
|
+
record_id = operation.get("id")
|
|
69
|
+
|
|
70
|
+
if not isinstance(record_id, str):
|
|
71
|
+
raise DatabaseError("Corrupt log: missing record id")
|
|
72
|
+
|
|
73
|
+
if action in ("insert", "update"):
|
|
74
|
+
document = operation.get("document")
|
|
75
|
+
|
|
76
|
+
if not isinstance(document, dict):
|
|
77
|
+
raise DatabaseError("Corrupt log: invalid document")
|
|
78
|
+
|
|
79
|
+
self.records[record_id] = document
|
|
80
|
+
|
|
81
|
+
elif action == "delete":
|
|
82
|
+
self.records.pop(record_id, None)
|
|
83
|
+
|
|
84
|
+
else:
|
|
85
|
+
raise DatabaseError(
|
|
86
|
+
f"Corrupt log: unknown action {action!r}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for entry in self.storage.read_all():
|
|
90
|
+
action = entry.get("action")
|
|
91
|
+
|
|
92
|
+
if action == "transaction":
|
|
93
|
+
operations = entry.get("operations")
|
|
94
|
+
|
|
95
|
+
if not isinstance(operations, list):
|
|
96
|
+
raise DatabaseError("Corrupt transaction log")
|
|
97
|
+
|
|
98
|
+
for operation in operations:
|
|
99
|
+
if not isinstance(operation, dict):
|
|
100
|
+
raise DatabaseError("Invalid transaction operation")
|
|
101
|
+
|
|
102
|
+
for operation in operations:
|
|
103
|
+
apply_operation(operation)
|
|
104
|
+
else:
|
|
105
|
+
apply_operation(entry)
|
|
106
|
+
|
|
107
|
+
self._rebuild_indexes()
|
|
108
|
+
|
|
109
|
+
@synchronized
|
|
110
|
+
def set_schema(self, schema: dict[str, type]):
|
|
111
|
+
"""Define allowed fields and their expected data types."""
|
|
112
|
+
if not isinstance(schema, dict) or not schema:
|
|
113
|
+
raise DatabaseError("Schema must be a non-empty dictionary")
|
|
114
|
+
|
|
115
|
+
for field, expected_type in schema.items():
|
|
116
|
+
if not isinstance(field, str) or not isinstance(expected_type, type):
|
|
117
|
+
raise DatabaseError("Invalid schema definition")
|
|
118
|
+
|
|
119
|
+
for document in self.records.values():
|
|
120
|
+
self._validate_document(document, schema)
|
|
121
|
+
|
|
122
|
+
self.schema = schema
|
|
123
|
+
|
|
124
|
+
def _validate_document(self, document, schema=None):
|
|
125
|
+
"""Validate document fields and data types against the schema."""
|
|
126
|
+
schema = schema if schema is not None else self.schema
|
|
127
|
+
|
|
128
|
+
if schema is None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
unknown_fields = set(document) - set(schema)
|
|
132
|
+
|
|
133
|
+
if unknown_fields:
|
|
134
|
+
raise DatabaseError(
|
|
135
|
+
f"Unknown fields: {', '.join(sorted(unknown_fields))}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
for field, value in document.items():
|
|
139
|
+
expected_type = schema[field]
|
|
140
|
+
|
|
141
|
+
if not isinstance(value, expected_type):
|
|
142
|
+
raise DatabaseError(
|
|
143
|
+
f"Field '{field}' must be of type {expected_type.__name__}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _rebuild_indexes(self):
|
|
147
|
+
"""Rebuild all indexes from current records."""
|
|
148
|
+
for field, index in self.indexes.items():
|
|
149
|
+
index.clear()
|
|
150
|
+
|
|
151
|
+
for record_id, document in self.records.items():
|
|
152
|
+
if field in document:
|
|
153
|
+
index.add(document[field], record_id)
|
|
154
|
+
|
|
155
|
+
def _write_operation(self, operation):
|
|
156
|
+
"""Buffer operations during a transaction."""
|
|
157
|
+
if self._transaction_operations is not None:
|
|
158
|
+
self._transaction_operations.append(operation)
|
|
159
|
+
else:
|
|
160
|
+
self.storage.append(operation)
|
|
161
|
+
|
|
162
|
+
@synchronized
|
|
163
|
+
def begin(self):
|
|
164
|
+
"""Start a thread-owned transaction."""
|
|
165
|
+
if self._transaction_snapshot is not None:
|
|
166
|
+
raise DatabaseError("Transaction already active")
|
|
167
|
+
|
|
168
|
+
self._lock.acquire()
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
self._transaction_snapshot = deepcopy(self.records)
|
|
172
|
+
self._transaction_operations = []
|
|
173
|
+
return "Transaction started"
|
|
174
|
+
|
|
175
|
+
except Exception:
|
|
176
|
+
self._lock.release()
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
@synchronized
|
|
180
|
+
def commit(self):
|
|
181
|
+
"""Atomically commit a transaction and release its lock."""
|
|
182
|
+
if self._transaction_snapshot is None:
|
|
183
|
+
raise DatabaseError("No active transaction")
|
|
184
|
+
|
|
185
|
+
transaction = {
|
|
186
|
+
"action": "transaction",
|
|
187
|
+
"operations": self._transaction_operations
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
if self._transaction_operations:
|
|
192
|
+
self.storage.append(transaction)
|
|
193
|
+
|
|
194
|
+
except Exception:
|
|
195
|
+
self.records = self._transaction_snapshot
|
|
196
|
+
self._transaction_snapshot = None
|
|
197
|
+
self._transaction_operations = None
|
|
198
|
+
self._rebuild_indexes()
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
else:
|
|
202
|
+
self._transaction_snapshot = None
|
|
203
|
+
self._transaction_operations = None
|
|
204
|
+
return "Transaction committed"
|
|
205
|
+
|
|
206
|
+
finally:
|
|
207
|
+
self._lock.release()
|
|
208
|
+
|
|
209
|
+
@synchronized
|
|
210
|
+
def rollback(self):
|
|
211
|
+
"""Restore previous records and release the transaction lock."""
|
|
212
|
+
if self._transaction_snapshot is None:
|
|
213
|
+
raise DatabaseError("No active transaction")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
self.records = self._transaction_snapshot
|
|
217
|
+
self._rebuild_indexes()
|
|
218
|
+
return "Transaction rolled back"
|
|
219
|
+
|
|
220
|
+
finally:
|
|
221
|
+
self._transaction_snapshot = None
|
|
222
|
+
self._transaction_operations = None
|
|
223
|
+
self._lock.release()
|
|
224
|
+
|
|
225
|
+
@synchronized
|
|
226
|
+
def insert(self, document: dict[str, Any]) -> str:
|
|
227
|
+
if not isinstance(document, dict):
|
|
228
|
+
raise TypeError("document must be a dictionary")
|
|
229
|
+
|
|
230
|
+
self._validate_document(document)
|
|
231
|
+
|
|
232
|
+
record_id = str(uuid4())
|
|
233
|
+
stored_document = dict(document)
|
|
234
|
+
|
|
235
|
+
self._write_operation({
|
|
236
|
+
"action": "insert",
|
|
237
|
+
"id": record_id,
|
|
238
|
+
"document": stored_document,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
self.records[record_id] = stored_document
|
|
242
|
+
|
|
243
|
+
for field, index in self.indexes.items():
|
|
244
|
+
if field in stored_document:
|
|
245
|
+
index.add(stored_document[field], record_id)
|
|
246
|
+
|
|
247
|
+
return record_id
|
|
248
|
+
|
|
249
|
+
@synchronized
|
|
250
|
+
def get(self, record_id: str) -> dict[str, Any]:
|
|
251
|
+
try:
|
|
252
|
+
return dict(self.records[record_id])
|
|
253
|
+
except KeyError as exc:
|
|
254
|
+
raise RecordNotFoundError(
|
|
255
|
+
f"No record found with id {record_id}"
|
|
256
|
+
) from exc
|
|
257
|
+
|
|
258
|
+
@synchronized
|
|
259
|
+
def all(self):
|
|
260
|
+
return [
|
|
261
|
+
{"_id": key, **dict(value)}
|
|
262
|
+
for key, value in self.records.items()
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
@synchronized
|
|
266
|
+
def update(self, record_id: str, changes: dict[str, Any]):
|
|
267
|
+
if record_id not in self.records:
|
|
268
|
+
raise RecordNotFoundError(
|
|
269
|
+
f"No record found with id {record_id}"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if not isinstance(changes, dict):
|
|
273
|
+
raise TypeError("changes must be a dictionary")
|
|
274
|
+
|
|
275
|
+
old_document = self.records[record_id]
|
|
276
|
+
updated = {**old_document, **changes}
|
|
277
|
+
|
|
278
|
+
self._validate_document(updated)
|
|
279
|
+
|
|
280
|
+
self._write_operation({
|
|
281
|
+
"action": "update",
|
|
282
|
+
"id": record_id,
|
|
283
|
+
"document": updated,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
for field, index in self.indexes.items():
|
|
287
|
+
if field in old_document:
|
|
288
|
+
index.remove(old_document[field], record_id)
|
|
289
|
+
|
|
290
|
+
self.records[record_id] = updated
|
|
291
|
+
|
|
292
|
+
for field, index in self.indexes.items():
|
|
293
|
+
if field in updated:
|
|
294
|
+
index.add(updated[field], record_id)
|
|
295
|
+
|
|
296
|
+
@synchronized
|
|
297
|
+
def delete(self, record_id: str):
|
|
298
|
+
if record_id not in self.records:
|
|
299
|
+
raise RecordNotFoundError(
|
|
300
|
+
f"No record found with id {record_id}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
old_document = self.records[record_id]
|
|
304
|
+
|
|
305
|
+
self._write_operation({
|
|
306
|
+
"action": "delete",
|
|
307
|
+
"id": record_id,
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
for field, index in self.indexes.items():
|
|
311
|
+
if field in old_document:
|
|
312
|
+
index.remove(old_document[field], record_id)
|
|
313
|
+
|
|
314
|
+
del self.records[record_id]
|
|
315
|
+
|
|
316
|
+
@synchronized
|
|
317
|
+
def create_index(self, field):
|
|
318
|
+
"""Create an index for a field."""
|
|
319
|
+
if field in self.indexes:
|
|
320
|
+
return "Index already exists"
|
|
321
|
+
|
|
322
|
+
index = Index(field)
|
|
323
|
+
|
|
324
|
+
for record_id, document in self.records.items():
|
|
325
|
+
if field in document:
|
|
326
|
+
index.add(document[field], record_id)
|
|
327
|
+
|
|
328
|
+
self.indexes[field] = index
|
|
329
|
+
|
|
330
|
+
return f"Index created for {field}"
|
|
331
|
+
|
|
332
|
+
@synchronized
|
|
333
|
+
def find_by_index(self, field, value):
|
|
334
|
+
"""Find records using an existing index."""
|
|
335
|
+
if field not in self.indexes:
|
|
336
|
+
raise DatabaseError(f"No index found for {field}")
|
|
337
|
+
|
|
338
|
+
record_ids = self.indexes[field].search(value)
|
|
339
|
+
|
|
340
|
+
return [
|
|
341
|
+
{"_id": record_id, **dict(self.records[record_id])}
|
|
342
|
+
for record_id in record_ids
|
|
343
|
+
if record_id in self.records
|
|
344
|
+
]
|
|
345
|
+
|
|
346
|
+
@synchronized
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@synchronized
|
|
351
|
+
def execute(self, query: str):
|
|
352
|
+
"""Execute queries and notify registered plugins."""
|
|
353
|
+
command = query.strip().upper()
|
|
354
|
+
|
|
355
|
+
if command == "BEGIN":
|
|
356
|
+
return self.begin()
|
|
357
|
+
|
|
358
|
+
if command == "COMMIT":
|
|
359
|
+
return self.commit()
|
|
360
|
+
|
|
361
|
+
if command == "ROLLBACK":
|
|
362
|
+
return self.rollback()
|
|
363
|
+
|
|
364
|
+
start = perf_counter()
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
result = execute_query(self, query)
|
|
368
|
+
except Exception:
|
|
369
|
+
elapsed_ms = (perf_counter() - start) * 1000
|
|
370
|
+
|
|
371
|
+
for name in self.plugins.list_plugins():
|
|
372
|
+
plugin = self.plugins.get(name)
|
|
373
|
+
|
|
374
|
+
if hasattr(plugin, "record_execution"):
|
|
375
|
+
plugin.record_execution(query, elapsed_ms, False)
|
|
376
|
+
|
|
377
|
+
raise
|
|
378
|
+
|
|
379
|
+
elapsed_ms = (perf_counter() - start) * 1000
|
|
380
|
+
|
|
381
|
+
for name in self.plugins.list_plugins():
|
|
382
|
+
plugin = self.plugins.get(name)
|
|
383
|
+
|
|
384
|
+
if hasattr(plugin, "record_query"):
|
|
385
|
+
plugin.record_query(query)
|
|
386
|
+
|
|
387
|
+
if hasattr(plugin, "record_execution"):
|
|
388
|
+
plugin.record_execution(query, elapsed_ms, True)
|
|
389
|
+
|
|
390
|
+
return result
|
|
391
|
+
|
minidb/executor.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from .query import parse, QueryError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def convert_value(value):
|
|
8
|
+
"""Convert query values into Python data types."""
|
|
9
|
+
value = value.strip()
|
|
10
|
+
|
|
11
|
+
if value.startswith('"') and value.endswith('"'):
|
|
12
|
+
return value[1:-1]
|
|
13
|
+
|
|
14
|
+
if value.startswith("'") and value.endswith("'"):
|
|
15
|
+
return value[1:-1]
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
return int(value)
|
|
19
|
+
except ValueError:
|
|
20
|
+
try:
|
|
21
|
+
return float(value)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def matches(record, condition):
|
|
27
|
+
"""Check whether a record satisfies a WHERE condition."""
|
|
28
|
+
if condition is None:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
field = condition["field"]
|
|
32
|
+
operator = condition["operator"]
|
|
33
|
+
expected = convert_value(condition["value"])
|
|
34
|
+
actual = record.get(field)
|
|
35
|
+
|
|
36
|
+
if actual is None:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
if operator == "=":
|
|
41
|
+
return actual == expected
|
|
42
|
+
if operator == "!=":
|
|
43
|
+
return actual != expected
|
|
44
|
+
if operator == ">":
|
|
45
|
+
return actual > expected
|
|
46
|
+
if operator == "<":
|
|
47
|
+
return actual < expected
|
|
48
|
+
if operator == ">=":
|
|
49
|
+
return actual >= expected
|
|
50
|
+
if operator == "<=":
|
|
51
|
+
return actual <= expected
|
|
52
|
+
except TypeError:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
raise QueryError(f"Unsupported operator: {operator}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_statistics(database, scan_type, examined, returned, start):
|
|
59
|
+
"""Save statistics about the latest SELECT query."""
|
|
60
|
+
database.last_query_stats = {
|
|
61
|
+
"scan_type": scan_type,
|
|
62
|
+
"records_examined": examined,
|
|
63
|
+
"records_returned": returned,
|
|
64
|
+
"execution_time_ms": round(
|
|
65
|
+
(time.perf_counter() - start) * 1000, 4
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def execute_select(database, query):
|
|
71
|
+
"""Execute SELECT and collect query statistics."""
|
|
72
|
+
start = time.perf_counter()
|
|
73
|
+
parsed = parse(query)
|
|
74
|
+
condition = parsed["condition"]
|
|
75
|
+
|
|
76
|
+
# Basic query planner.
|
|
77
|
+
if (
|
|
78
|
+
condition is not None
|
|
79
|
+
and condition["operator"] == "="
|
|
80
|
+
and condition["field"] in database.indexes
|
|
81
|
+
):
|
|
82
|
+
value = convert_value(condition["value"])
|
|
83
|
+
|
|
84
|
+
candidates = database.find_by_index(
|
|
85
|
+
condition["field"], value
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
scan_type = "INDEX SCAN"
|
|
89
|
+
examined = len(candidates)
|
|
90
|
+
|
|
91
|
+
else:
|
|
92
|
+
candidates = database.all()
|
|
93
|
+
scan_type = "FULL SCAN"
|
|
94
|
+
examined = len(candidates)
|
|
95
|
+
|
|
96
|
+
filtered = [
|
|
97
|
+
record for record in candidates
|
|
98
|
+
if matches(record, condition)
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
# Handle COUNT.
|
|
102
|
+
aggregate = parsed["fields"][0]
|
|
103
|
+
|
|
104
|
+
if aggregate == "COUNT(*)":
|
|
105
|
+
result = [{"COUNT(*)": len(filtered)}]
|
|
106
|
+
|
|
107
|
+
elif aggregate.startswith("SUM("):
|
|
108
|
+
field = aggregate[4:-1]
|
|
109
|
+
|
|
110
|
+
values = [
|
|
111
|
+
record[field] for record in filtered
|
|
112
|
+
if isinstance(record.get(field), (int, float))
|
|
113
|
+
and not isinstance(record.get(field), bool)
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
result = [{aggregate: sum(values)}]
|
|
117
|
+
|
|
118
|
+
elif aggregate.startswith("AVG("):
|
|
119
|
+
field = aggregate[4:-1]
|
|
120
|
+
|
|
121
|
+
values = [
|
|
122
|
+
record[field] for record in filtered
|
|
123
|
+
if isinstance(record.get(field), (int, float))
|
|
124
|
+
and not isinstance(record.get(field), bool)
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
average = sum(values) / len(values) if values else 0
|
|
128
|
+
result = [{aggregate: average}]
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
# Apply ORDER BY.
|
|
132
|
+
order_by = parsed.get("order_by")
|
|
133
|
+
direction = parsed.get("direction", "ASC")
|
|
134
|
+
|
|
135
|
+
if order_by:
|
|
136
|
+
filtered.sort(
|
|
137
|
+
key=lambda record: (
|
|
138
|
+
record.get(order_by) is None,
|
|
139
|
+
record.get(order_by)
|
|
140
|
+
),
|
|
141
|
+
reverse=(direction == "DESC")
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Apply field projection.
|
|
145
|
+
fields = parsed["fields"]
|
|
146
|
+
|
|
147
|
+
if fields == ["*"]:
|
|
148
|
+
result = filtered
|
|
149
|
+
else:
|
|
150
|
+
result = [
|
|
151
|
+
{
|
|
152
|
+
"_id": record["_id"],
|
|
153
|
+
**{
|
|
154
|
+
field: record[field]
|
|
155
|
+
for field in fields
|
|
156
|
+
if field in record
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for record in filtered
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
save_statistics(
|
|
163
|
+
database,
|
|
164
|
+
scan_type,
|
|
165
|
+
examined,
|
|
166
|
+
len(result),
|
|
167
|
+
start
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def execute_query(database, query):
|
|
174
|
+
"""Execute SELECT, INSERT, UPDATE and DELETE."""
|
|
175
|
+
parsed = parse(query)
|
|
176
|
+
command = parsed["type"]
|
|
177
|
+
|
|
178
|
+
if command == "SELECT":
|
|
179
|
+
return execute_select(database, query)
|
|
180
|
+
|
|
181
|
+
if command == "INSERT":
|
|
182
|
+
document = {
|
|
183
|
+
field: convert_value(value)
|
|
184
|
+
for field, value in parsed["document"].items()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return database.insert(document)
|
|
188
|
+
|
|
189
|
+
if command == "UPDATE":
|
|
190
|
+
changes = {
|
|
191
|
+
field: convert_value(value)
|
|
192
|
+
for field, value in parsed["changes"].items()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
count = 0
|
|
196
|
+
|
|
197
|
+
for record in database.all():
|
|
198
|
+
if matches(record, parsed["condition"]):
|
|
199
|
+
database.update(record["_id"], changes)
|
|
200
|
+
count += 1
|
|
201
|
+
|
|
202
|
+
return count
|
|
203
|
+
|
|
204
|
+
if command == "DELETE":
|
|
205
|
+
count = 0
|
|
206
|
+
|
|
207
|
+
for record in database.all():
|
|
208
|
+
if matches(record, parsed["condition"]):
|
|
209
|
+
database.delete(record["_id"])
|
|
210
|
+
count += 1
|
|
211
|
+
|
|
212
|
+
return count
|
|
213
|
+
|
|
214
|
+
raise QueryError("Unsupported query")
|
minidb/index.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
class Index:
|
|
3
|
+
"""Simple in-memory index for faster record lookup."""
|
|
4
|
+
|
|
5
|
+
def __init__(self, field):
|
|
6
|
+
self.field = field
|
|
7
|
+
self.entries = {}
|
|
8
|
+
|
|
9
|
+
def add(self, value, record_id):
|
|
10
|
+
"""Add a record ID to the index."""
|
|
11
|
+
try:
|
|
12
|
+
self.entries.setdefault(value, set()).add(record_id)
|
|
13
|
+
except TypeError:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
def remove(self, value, record_id):
|
|
17
|
+
"""Remove a record ID from the index."""
|
|
18
|
+
try:
|
|
19
|
+
if value in self.entries:
|
|
20
|
+
self.entries[value].discard(record_id)
|
|
21
|
+
|
|
22
|
+
if not self.entries[value]:
|
|
23
|
+
del self.entries[value]
|
|
24
|
+
except TypeError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def search(self, value):
|
|
28
|
+
"""Find record IDs matching an exact value."""
|
|
29
|
+
try:
|
|
30
|
+
return set(self.entries.get(value, set()))
|
|
31
|
+
except TypeError:
|
|
32
|
+
return set()
|
|
33
|
+
|
|
34
|
+
def clear(self):
|
|
35
|
+
"""Remove all index entries."""
|
|
36
|
+
self.entries.clear()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
|
|
2
|
+
"""Plugin management for MiniDB."""
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PluginManager:
|
|
8
|
+
"""Register, retrieve, and manage MiniDB plugins."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._plugins: Dict[str, Any] = {}
|
|
12
|
+
|
|
13
|
+
def register(self, name: str, plugin: Any) -> None:
|
|
14
|
+
"""Register a plugin with a unique name."""
|
|
15
|
+
if not name:
|
|
16
|
+
raise ValueError("Plugin name cannot be empty.")
|
|
17
|
+
|
|
18
|
+
if name in self._plugins:
|
|
19
|
+
raise ValueError(f"Plugin '{name}' is already registered.")
|
|
20
|
+
|
|
21
|
+
if not callable(getattr(plugin, "activate", None)):
|
|
22
|
+
raise TypeError("A plugin must implement activate(database).")
|
|
23
|
+
|
|
24
|
+
self._plugins[name] = plugin
|
|
25
|
+
|
|
26
|
+
def activate(self, name: str, database: Any) -> None:
|
|
27
|
+
"""Activate a registered plugin."""
|
|
28
|
+
if name not in self._plugins:
|
|
29
|
+
raise KeyError(f"Plugin '{name}' is not registered.")
|
|
30
|
+
|
|
31
|
+
self._plugins[name].activate(database)
|
|
32
|
+
|
|
33
|
+
def get(self, name: str) -> Any:
|
|
34
|
+
"""Return a registered plugin."""
|
|
35
|
+
if name not in self._plugins:
|
|
36
|
+
raise KeyError(f"Plugin '{name}' is not registered.")
|
|
37
|
+
|
|
38
|
+
return self._plugins[name]
|
|
39
|
+
|
|
40
|
+
def list_plugins(self) -> list[str]:
|
|
41
|
+
"""Return the names of all registered plugins."""
|
|
42
|
+
return list(self._plugins.keys())
|