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