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 ADDED
@@ -0,0 +1,4 @@
1
+ """MiniDB: a small educational embedded database engine."""
2
+ from .database import Database
3
+
4
+ __all__ = ["Database"]
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,6 @@
1
+
2
+ """MiniDB plugin system."""
3
+
4
+ from .manager import PluginManager
5
+
6
+ __all__ = ["PluginManager"]
@@ -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())