minidb-sql 1.3.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_sql/__init__.py +168 -0
- minidb_sql/column.py +170 -0
- minidb_sql/database.py +258 -0
- minidb_sql/errors.py +176 -0
- minidb_sql/index.py +222 -0
- minidb_sql/parser.py +1278 -0
- minidb_sql/persistence.py +72 -0
- minidb_sql/planner.py +167 -0
- minidb_sql/query.py +1077 -0
- minidb_sql/table.py +274 -0
- minidb_sql/types.py +189 -0
- minidb_sql-1.3.0.dist-info/METADATA +379 -0
- minidb_sql-1.3.0.dist-info/RECORD +16 -0
- minidb_sql-1.3.0.dist-info/WHEEL +5 -0
- minidb_sql-1.3.0.dist-info/licenses/LICENSE +21 -0
- minidb_sql-1.3.0.dist-info/top_level.txt +1 -0
minidb_sql/errors.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Custom exceptions for MiniDB."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MiniDBError(Exception):
|
|
5
|
+
"""Base exception for all MiniDB errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TableError(MiniDBError):
|
|
11
|
+
"""Errors related to table operations."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TableNotFoundError(TableError):
|
|
17
|
+
"""Raised when a table does not exist."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, table_name: str):
|
|
20
|
+
self.table_name = table_name
|
|
21
|
+
super().__init__(f"Table '{table_name}' not found")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TableExistsError(TableError):
|
|
25
|
+
"""Raised when a table already exists."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, table_name: str):
|
|
28
|
+
self.table_name = table_name
|
|
29
|
+
super().__init__(f"Table '{table_name}' already exists")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ColumnError(MiniDBError):
|
|
33
|
+
"""Errors related to column operations."""
|
|
34
|
+
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ColumnNotFoundError(ColumnError):
|
|
39
|
+
"""Raised when a column does not exist."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, column_name: str, table_name: str | None = None):
|
|
42
|
+
self.column_name = column_name
|
|
43
|
+
self.table_name = table_name
|
|
44
|
+
msg = f"Column '{column_name}' not found"
|
|
45
|
+
if table_name:
|
|
46
|
+
msg += f" in table '{table_name}'"
|
|
47
|
+
super().__init__(msg)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ColumnExistsError(ColumnError):
|
|
51
|
+
"""Raised when a column already exists."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, column_name: str, table_name: str):
|
|
54
|
+
self.column_name = column_name
|
|
55
|
+
self.table_name = table_name
|
|
56
|
+
super().__init__(f"Column '{column_name}' already exists in table '{table_name}'")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TypeError_(MiniDBError):
|
|
60
|
+
"""Errors related to type validation."""
|
|
61
|
+
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TypeMismatchError(TypeError_):
|
|
66
|
+
"""Raised when a value doesn't match the column type."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, column_name: str, expected_type: str, actual_type: str):
|
|
69
|
+
self.column_name = column_name
|
|
70
|
+
self.expected_type = expected_type
|
|
71
|
+
self.actual_type = actual_type
|
|
72
|
+
super().__init__(f"Type mismatch for column '{column_name}': expected {expected_type}, got {actual_type}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NullValueError(TypeError_):
|
|
76
|
+
"""Raised when a NULL value is provided for a non-nullable column."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, column_name: str):
|
|
79
|
+
self.column_name = column_name
|
|
80
|
+
super().__init__(f"NULL value not allowed for column '{column_name}'")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ParseError(MiniDBError):
|
|
84
|
+
"""Errors related to SQL parsing."""
|
|
85
|
+
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SyntaxError_(ParseError):
|
|
90
|
+
"""Raised when there's a syntax error in the SQL query."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, message: str, position: int | None = None):
|
|
93
|
+
self.position = position
|
|
94
|
+
msg = f'Syntax error: {message}'
|
|
95
|
+
if position is not None:
|
|
96
|
+
msg += f' at position {position}'
|
|
97
|
+
super().__init__(msg)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class QueryError(MiniDBError):
|
|
101
|
+
"""Errors related to query execution."""
|
|
102
|
+
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InvalidQueryError(QueryError):
|
|
107
|
+
"""Raised when a query is invalid."""
|
|
108
|
+
|
|
109
|
+
def __init__(self, message: str):
|
|
110
|
+
super().__init__(f'Invalid query: {message}')
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AmbiguousColumnError(QueryError):
|
|
114
|
+
"""Raised when a column reference is ambiguous."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, column_name: str):
|
|
117
|
+
self.column_name = column_name
|
|
118
|
+
super().__init__(f"Ambiguous column reference: '{column_name}'")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ConstraintError(MiniDBError):
|
|
122
|
+
"""Errors related to constraint violations."""
|
|
123
|
+
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class PrimaryKeyError(ConstraintError):
|
|
128
|
+
"""Raised when a primary key constraint is violated."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, message: str):
|
|
131
|
+
super().__init__(f'Primary key constraint violation: {message}')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class DuplicateKeyError(PrimaryKeyError):
|
|
135
|
+
"""Raised when a duplicate primary key is inserted."""
|
|
136
|
+
|
|
137
|
+
def __init__(self, key_value):
|
|
138
|
+
self.key_value = key_value
|
|
139
|
+
super().__init__(f'Duplicate primary key value: {key_value}')
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class PersistenceError(MiniDBError):
|
|
143
|
+
"""Errors related to persistence operations."""
|
|
144
|
+
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class FileReadError(PersistenceError):
|
|
149
|
+
"""Raised when a file cannot be read."""
|
|
150
|
+
|
|
151
|
+
def __init__(self, filepath: str, reason: str | None = None):
|
|
152
|
+
self.filepath = filepath
|
|
153
|
+
msg = f'Failed to read file: {filepath}'
|
|
154
|
+
if reason:
|
|
155
|
+
msg += f' - {reason}'
|
|
156
|
+
super().__init__(msg)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FileWriteError(PersistenceError):
|
|
160
|
+
"""Raised when a file cannot be written."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, filepath: str, reason: str | None = None):
|
|
163
|
+
self.filepath = filepath
|
|
164
|
+
msg = f'Failed to write file: {filepath}'
|
|
165
|
+
if reason:
|
|
166
|
+
msg += f' - {reason}'
|
|
167
|
+
super().__init__(msg)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class VersionMismatchError(PersistenceError):
|
|
171
|
+
"""Raised when the persistence file version is incompatible."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, version: str, expected: str):
|
|
174
|
+
self.version = version
|
|
175
|
+
self.expected = expected
|
|
176
|
+
super().__init__(f'Incompatible persistence file version: {version} (expected {expected})')
|
minidb_sql/index.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Hash-based index implementation for MiniDB."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from bisect import bisect_left, bisect_right
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import TypeMismatchError
|
|
9
|
+
|
|
10
|
+
# NaN is not equal to itself, so it cannot be a dict key as a float.
|
|
11
|
+
_NAN = object()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _index_key(value: Any) -> Any:
|
|
15
|
+
"""Map NaN to one key so a second NaN primary key is a duplicate."""
|
|
16
|
+
if isinstance(value, float) and math.isnan(value):
|
|
17
|
+
return _NAN
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HashIndex:
|
|
23
|
+
"""
|
|
24
|
+
Hash-based index for fast equality lookups.
|
|
25
|
+
|
|
26
|
+
Maps values to sets of row IDs for O(1) equality lookups.
|
|
27
|
+
For range queries, maintains a sorted list of keys.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
column_name: str
|
|
31
|
+
_index: dict[Any, set[int]] = field(default_factory=dict)
|
|
32
|
+
_sorted_keys: list[Any] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
def insert(self, value: Any, row_id: int) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Add a row ID to the index for a given value.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
value: The indexed value
|
|
40
|
+
row_id: The row ID to associate with this value
|
|
41
|
+
"""
|
|
42
|
+
key = _index_key(value)
|
|
43
|
+
if key not in self._index:
|
|
44
|
+
self._index[key] = set()
|
|
45
|
+
# NaN is not ordered. Equality still sees it via _NAN.
|
|
46
|
+
if key is not _NAN:
|
|
47
|
+
self._insert_sorted_key(value)
|
|
48
|
+
self._index[key].add(row_id)
|
|
49
|
+
|
|
50
|
+
def _insert_sorted_key(self, value: Any) -> None:
|
|
51
|
+
"""Insert a key into the sorted keys list."""
|
|
52
|
+
pos = bisect_left(self._sorted_keys, value)
|
|
53
|
+
if pos == len(self._sorted_keys) or self._sorted_keys[pos] != value:
|
|
54
|
+
self._sorted_keys.insert(pos, value)
|
|
55
|
+
|
|
56
|
+
def delete(self, value: Any, row_id: int) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Remove a row ID from the index for a given value.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
value: The indexed value
|
|
62
|
+
row_id: The row ID to remove
|
|
63
|
+
"""
|
|
64
|
+
key = _index_key(value)
|
|
65
|
+
if key in self._index:
|
|
66
|
+
self._index[key].discard(row_id)
|
|
67
|
+
if not self._index[key]:
|
|
68
|
+
del self._index[key]
|
|
69
|
+
if key is not _NAN:
|
|
70
|
+
pos = bisect_left(self._sorted_keys, value)
|
|
71
|
+
if pos < len(self._sorted_keys) and self._sorted_keys[pos] == value:
|
|
72
|
+
self._sorted_keys.pop(pos)
|
|
73
|
+
|
|
74
|
+
def lookup(self, value: Any) -> set[int]:
|
|
75
|
+
"""
|
|
76
|
+
Look up all row IDs for a given value.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
value: The value to look up
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Set of row IDs, empty if value not found
|
|
83
|
+
"""
|
|
84
|
+
return self._index.get(_index_key(value), set()).copy()
|
|
85
|
+
|
|
86
|
+
def range_lookup(self, op: str, value: Any) -> set[int]:
|
|
87
|
+
"""
|
|
88
|
+
Look up row IDs for range conditions.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
op: Comparison operator ('<', '<=', '>', '>=')
|
|
92
|
+
value: The comparison value
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Set of row IDs matching the condition
|
|
96
|
+
"""
|
|
97
|
+
result: set[int] = set()
|
|
98
|
+
try:
|
|
99
|
+
if op == '<':
|
|
100
|
+
# All keys less than value
|
|
101
|
+
pos = bisect_left(self._sorted_keys, value)
|
|
102
|
+
for key in self._sorted_keys[:pos]:
|
|
103
|
+
result.update(self._index[key])
|
|
104
|
+
elif op == '<=':
|
|
105
|
+
# All keys less than or equal to value
|
|
106
|
+
pos = bisect_right(self._sorted_keys, value)
|
|
107
|
+
for key in self._sorted_keys[:pos]:
|
|
108
|
+
result.update(self._index[key])
|
|
109
|
+
elif op == '>':
|
|
110
|
+
# All keys greater than value
|
|
111
|
+
pos = bisect_right(self._sorted_keys, value)
|
|
112
|
+
for key in self._sorted_keys[pos:]:
|
|
113
|
+
result.update(self._index[key])
|
|
114
|
+
elif op == '>=':
|
|
115
|
+
# All keys greater than or equal to value
|
|
116
|
+
pos = bisect_left(self._sorted_keys, value)
|
|
117
|
+
for key in self._sorted_keys[pos:]:
|
|
118
|
+
result.update(self._index[key])
|
|
119
|
+
except TypeError as exc:
|
|
120
|
+
expected = type(self._sorted_keys[0]).__name__ if self._sorted_keys else 'unknown'
|
|
121
|
+
raise TypeMismatchError(self.column_name, expected, type(value).__name__) from exc
|
|
122
|
+
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
def has_value(self, value: Any) -> bool:
|
|
126
|
+
"""Check if a value exists in the index."""
|
|
127
|
+
return _index_key(value) in self._index
|
|
128
|
+
|
|
129
|
+
def clear(self) -> None:
|
|
130
|
+
"""Clear all entries from the index."""
|
|
131
|
+
self._index.clear()
|
|
132
|
+
self._sorted_keys.clear()
|
|
133
|
+
|
|
134
|
+
def rebuild(self, rows: list[dict], row_ids: list[int]) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Rebuild the index from scratch.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
rows: List of row dictionaries
|
|
140
|
+
row_ids: Corresponding row IDs
|
|
141
|
+
"""
|
|
142
|
+
self.clear()
|
|
143
|
+
for row, row_id in zip(rows, row_ids, strict=False):
|
|
144
|
+
if self.column_name in row:
|
|
145
|
+
value = row[self.column_name]
|
|
146
|
+
if value is not None:
|
|
147
|
+
self.insert(value, row_id)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def size(self) -> int:
|
|
151
|
+
"""Get the number of unique keys in the index."""
|
|
152
|
+
return len(self._index)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def total_entries(self) -> int:
|
|
156
|
+
"""Get the total number of indexed entries."""
|
|
157
|
+
return sum(len(ids) for ids in self._index.values())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class IndexManager:
|
|
162
|
+
"""Manages multiple indexes for a table."""
|
|
163
|
+
|
|
164
|
+
_indexes: dict[str, HashIndex] = field(default_factory=dict)
|
|
165
|
+
|
|
166
|
+
def create_index(self, column_name: str) -> HashIndex:
|
|
167
|
+
"""
|
|
168
|
+
Create a new index on a column.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
column_name: Name of the column to index
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The created index
|
|
175
|
+
"""
|
|
176
|
+
if column_name in self._indexes:
|
|
177
|
+
raise ValueError(f"Index already exists on column '{column_name}'")
|
|
178
|
+
index = HashIndex(column_name=column_name)
|
|
179
|
+
self._indexes[column_name] = index
|
|
180
|
+
return index
|
|
181
|
+
|
|
182
|
+
def get_index(self, column_name: str) -> HashIndex | None:
|
|
183
|
+
"""Get an index by column name."""
|
|
184
|
+
return self._indexes.get(column_name)
|
|
185
|
+
|
|
186
|
+
def has_index(self, column_name: str) -> bool:
|
|
187
|
+
"""Check if an index exists for a column."""
|
|
188
|
+
return column_name in self._indexes
|
|
189
|
+
|
|
190
|
+
def drop_index(self, column_name: str) -> None:
|
|
191
|
+
"""Drop an index by column name."""
|
|
192
|
+
if column_name in self._indexes:
|
|
193
|
+
del self._indexes[column_name]
|
|
194
|
+
|
|
195
|
+
def insert_row(self, row: dict, row_id: int) -> None:
|
|
196
|
+
"""Insert a row into all indexes."""
|
|
197
|
+
for column_name, index in self._indexes.items():
|
|
198
|
+
if column_name in row and row[column_name] is not None:
|
|
199
|
+
index.insert(row[column_name], row_id)
|
|
200
|
+
|
|
201
|
+
def delete_row(self, row: dict, row_id: int) -> None:
|
|
202
|
+
"""Delete a row from all indexes."""
|
|
203
|
+
for column_name, index in self._indexes.items():
|
|
204
|
+
if column_name in row and row[column_name] is not None:
|
|
205
|
+
index.delete(row[column_name], row_id)
|
|
206
|
+
|
|
207
|
+
def update_row(self, old_row: dict, new_row: dict, row_id: int) -> None:
|
|
208
|
+
"""Update a row in all indexes."""
|
|
209
|
+
for column_name, index in self._indexes.items():
|
|
210
|
+
old_value = old_row.get(column_name)
|
|
211
|
+
new_value = new_row.get(column_name)
|
|
212
|
+
|
|
213
|
+
if old_value != new_value:
|
|
214
|
+
if old_value is not None:
|
|
215
|
+
index.delete(old_value, row_id)
|
|
216
|
+
if new_value is not None:
|
|
217
|
+
index.insert(new_value, row_id)
|
|
218
|
+
|
|
219
|
+
def clear(self) -> None:
|
|
220
|
+
"""Clear all indexes."""
|
|
221
|
+
for index in self._indexes.values():
|
|
222
|
+
index.clear()
|