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/table.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Table storage and row management for MiniDB."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Iterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .column import Schema
|
|
8
|
+
from .errors import ColumnNotFoundError, DuplicateKeyError
|
|
9
|
+
from .index import IndexManager
|
|
10
|
+
from .types import Row
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Table:
|
|
15
|
+
"""
|
|
16
|
+
Represents a database table with rows and a schema.
|
|
17
|
+
|
|
18
|
+
Stores rows as dictionaries and maintains indexes for fast lookups.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
schema: Schema
|
|
23
|
+
_rows: list[Row] = field(default_factory=list)
|
|
24
|
+
_row_ids: list[int] = field(default_factory=list)
|
|
25
|
+
_next_row_id: int = field(default=0)
|
|
26
|
+
_index_manager: IndexManager = field(default_factory=IndexManager)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
"""Initialize indexes after table creation."""
|
|
30
|
+
# Create index on primary key if exists
|
|
31
|
+
if self.schema.primary_key:
|
|
32
|
+
self._index_manager.create_index(self.schema.primary_key)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def row_count(self) -> int:
|
|
36
|
+
"""Get the number of rows in the table."""
|
|
37
|
+
return len(self._rows)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def columns(self) -> list[str]:
|
|
41
|
+
"""Get list of column names."""
|
|
42
|
+
return self.schema.column_names
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def primary_key(self) -> str | None:
|
|
46
|
+
"""Get the primary key column name."""
|
|
47
|
+
return self.schema.primary_key
|
|
48
|
+
|
|
49
|
+
def insert(self, row: dict) -> int:
|
|
50
|
+
"""
|
|
51
|
+
Insert a new row into the table.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
row: Row data as a dictionary
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The row ID of the inserted row
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
DuplicateKeyError: If primary key already exists
|
|
61
|
+
"""
|
|
62
|
+
# Validate and normalize the row
|
|
63
|
+
validated_row = self.schema.validate_row(row, allow_missing=True)
|
|
64
|
+
|
|
65
|
+
# Check for duplicate primary key
|
|
66
|
+
pk_column = self.schema.primary_key
|
|
67
|
+
if pk_column:
|
|
68
|
+
pk_value = validated_row[pk_column]
|
|
69
|
+
if pk_value is not None:
|
|
70
|
+
index = self._index_manager.get_index(pk_column)
|
|
71
|
+
if index and index.has_value(pk_value):
|
|
72
|
+
raise DuplicateKeyError(pk_value)
|
|
73
|
+
|
|
74
|
+
# Assign row ID
|
|
75
|
+
row_id = self._next_row_id
|
|
76
|
+
self._next_row_id += 1
|
|
77
|
+
|
|
78
|
+
# Store the row
|
|
79
|
+
self._rows.append(validated_row)
|
|
80
|
+
self._row_ids.append(row_id)
|
|
81
|
+
|
|
82
|
+
# Update indexes
|
|
83
|
+
self._index_manager.insert_row(validated_row, row_id)
|
|
84
|
+
|
|
85
|
+
return row_id
|
|
86
|
+
|
|
87
|
+
def get_row_by_id(self, row_id: int) -> Row | None:
|
|
88
|
+
"""Get a row by its ID."""
|
|
89
|
+
try:
|
|
90
|
+
idx = self._row_ids.index(row_id)
|
|
91
|
+
return self._rows[idx].copy()
|
|
92
|
+
except ValueError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def rows_for_ids(self, row_ids: Iterable[int]) -> list[tuple[int, Row]]:
|
|
96
|
+
"""Fetch many rows by id in one pass.
|
|
97
|
+
|
|
98
|
+
get_row_by_id walks every stored id. A primary-key range that
|
|
99
|
+
matches thousands of rows was slower than reading the table once.
|
|
100
|
+
"""
|
|
101
|
+
position = {row_id: index for index, row_id in enumerate(self._row_ids)}
|
|
102
|
+
found: list[tuple[int, Row]] = []
|
|
103
|
+
for row_id in row_ids:
|
|
104
|
+
index = position.get(row_id)
|
|
105
|
+
if index is not None:
|
|
106
|
+
found.append((row_id, self._rows[index].copy()))
|
|
107
|
+
return found
|
|
108
|
+
|
|
109
|
+
def get_row_id_by_pk(self, pk_value: Any) -> int | None:
|
|
110
|
+
"""Get a row ID by primary key value."""
|
|
111
|
+
pk_column = self.schema.primary_key
|
|
112
|
+
if not pk_column:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
index = self._index_manager.get_index(pk_column)
|
|
116
|
+
if not index:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
row_ids = index.lookup(pk_value)
|
|
120
|
+
return next(iter(row_ids)) if row_ids else None
|
|
121
|
+
|
|
122
|
+
def get_all_rows(self) -> list[Row]:
|
|
123
|
+
"""Get all rows in the table."""
|
|
124
|
+
return [row.copy() for row in self._rows]
|
|
125
|
+
|
|
126
|
+
def get_rows_with_ids(self) -> list[tuple[int, Row]]:
|
|
127
|
+
"""Get all rows with their IDs."""
|
|
128
|
+
return [(row_id, row.copy()) for row_id, row in zip(self._row_ids, self._rows, strict=False)]
|
|
129
|
+
|
|
130
|
+
def update_rows(self, row_ids: set[int], updates: dict) -> int:
|
|
131
|
+
"""
|
|
132
|
+
Update rows with the given IDs.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
row_ids: Set of row IDs to update
|
|
136
|
+
updates: Dictionary of column updates
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Number of rows updated
|
|
140
|
+
"""
|
|
141
|
+
if not row_ids or not updates:
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
# Plan every row before writing. A later duplicate must not keep
|
|
145
|
+
# the first row's new primary key.
|
|
146
|
+
planned: list[tuple[int, int, dict, dict]] = []
|
|
147
|
+
pk_column = self.schema.primary_key
|
|
148
|
+
new_keys: dict[Any, int] = {}
|
|
149
|
+
for i, row_id in enumerate(self._row_ids):
|
|
150
|
+
if row_id not in row_ids:
|
|
151
|
+
continue
|
|
152
|
+
old_row = self._rows[i]
|
|
153
|
+
new_row = old_row.copy()
|
|
154
|
+
for col, val in updates.items():
|
|
155
|
+
col_def = self.schema.get_column(col)
|
|
156
|
+
if col_def is None:
|
|
157
|
+
raise ColumnNotFoundError(col, self.name)
|
|
158
|
+
new_row[col] = col_def.validate(val)
|
|
159
|
+
validated = self.schema.validate_row(new_row, allow_missing=True)
|
|
160
|
+
if pk_column and pk_column in updates:
|
|
161
|
+
new_pk = validated[pk_column]
|
|
162
|
+
if new_pk is not None:
|
|
163
|
+
if new_pk in new_keys:
|
|
164
|
+
raise DuplicateKeyError(new_pk)
|
|
165
|
+
index = self.get_index(pk_column)
|
|
166
|
+
if index and index.has_value(new_pk):
|
|
167
|
+
existing_ids = index.lookup(new_pk)
|
|
168
|
+
if any(existing_id not in row_ids for existing_id in existing_ids):
|
|
169
|
+
raise DuplicateKeyError(new_pk)
|
|
170
|
+
new_keys[new_pk] = row_id
|
|
171
|
+
planned.append((i, row_id, old_row, validated))
|
|
172
|
+
|
|
173
|
+
for i, row_id, old_row, validated in planned:
|
|
174
|
+
self._index_manager.update_row(old_row, validated, row_id)
|
|
175
|
+
self._rows[i] = validated
|
|
176
|
+
|
|
177
|
+
return len(planned)
|
|
178
|
+
|
|
179
|
+
def delete_rows(self, row_ids: set[int]) -> int:
|
|
180
|
+
"""
|
|
181
|
+
Delete rows with the given IDs.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
row_ids: Set of row IDs to delete
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Number of rows deleted
|
|
188
|
+
"""
|
|
189
|
+
if not row_ids:
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
# Find indices to delete (in reverse order for safe removal)
|
|
193
|
+
indices_to_delete = []
|
|
194
|
+
for i, row_id in enumerate(self._row_ids):
|
|
195
|
+
if row_id in row_ids:
|
|
196
|
+
indices_to_delete.append(i)
|
|
197
|
+
|
|
198
|
+
# Delete in reverse order
|
|
199
|
+
count = 0
|
|
200
|
+
for i in reversed(indices_to_delete):
|
|
201
|
+
row = self._rows[i]
|
|
202
|
+
row_id = self._row_ids[i]
|
|
203
|
+
|
|
204
|
+
# Update indexes
|
|
205
|
+
self._index_manager.delete_row(row, row_id)
|
|
206
|
+
|
|
207
|
+
# Remove row
|
|
208
|
+
self._rows.pop(i)
|
|
209
|
+
self._row_ids.pop(i)
|
|
210
|
+
count += 1
|
|
211
|
+
|
|
212
|
+
return count
|
|
213
|
+
|
|
214
|
+
def scan(self) -> Iterator[tuple[int, Row]]:
|
|
215
|
+
"""Iterate over all rows with their IDs."""
|
|
216
|
+
for row_id, row in zip(self._row_ids, self._rows, strict=False):
|
|
217
|
+
yield row_id, row.copy()
|
|
218
|
+
|
|
219
|
+
def get_index(self, column_name: str):
|
|
220
|
+
"""Get an index for a column."""
|
|
221
|
+
return self._index_manager.get_index(column_name)
|
|
222
|
+
|
|
223
|
+
def has_index(self, column_name: str) -> bool:
|
|
224
|
+
"""Check if an index exists for a column."""
|
|
225
|
+
return self._index_manager.has_index(column_name)
|
|
226
|
+
|
|
227
|
+
def clear(self) -> None:
|
|
228
|
+
"""Remove all rows from the table."""
|
|
229
|
+
self._rows.clear()
|
|
230
|
+
self._row_ids.clear()
|
|
231
|
+
self._next_row_id = 0
|
|
232
|
+
self._index_manager.clear()
|
|
233
|
+
|
|
234
|
+
def to_dict(self) -> dict:
|
|
235
|
+
"""Serialize table to a dictionary."""
|
|
236
|
+
return {
|
|
237
|
+
'name': self.name,
|
|
238
|
+
'schema': self.schema.to_dict(),
|
|
239
|
+
'rows': self._rows,
|
|
240
|
+
'row_ids': list(self._row_ids),
|
|
241
|
+
'next_row_id': self._next_row_id,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def from_dict(cls, data: dict) -> 'Table':
|
|
246
|
+
"""Deserialize table from a dictionary."""
|
|
247
|
+
schema = Schema.from_dict(data['schema'])
|
|
248
|
+
table = cls(name=data['name'], schema=schema)
|
|
249
|
+
# Same checks as insert. A hand-edited file must not keep two primary
|
|
250
|
+
# keys or a value the column cannot store.
|
|
251
|
+
table._rows = [schema.validate_row(row, allow_missing=True) for row in data['rows']]
|
|
252
|
+
table._next_row_id = data.get('next_row_id', len(table._rows))
|
|
253
|
+
|
|
254
|
+
# Files written before row_ids existed keep 0..n-1.
|
|
255
|
+
saved_ids = data.get('row_ids')
|
|
256
|
+
if isinstance(saved_ids, list) and len(saved_ids) == len(table._rows):
|
|
257
|
+
table._row_ids = list(saved_ids)
|
|
258
|
+
else:
|
|
259
|
+
table._row_ids = list(range(len(table._rows)))
|
|
260
|
+
|
|
261
|
+
pk = table.schema.primary_key
|
|
262
|
+
if pk:
|
|
263
|
+
index = table._index_manager.get_index(pk)
|
|
264
|
+
if index is None:
|
|
265
|
+
index = table._index_manager.create_index(pk)
|
|
266
|
+
for row, row_id in zip(table._rows, table._row_ids, strict=True):
|
|
267
|
+
value = row.get(pk)
|
|
268
|
+
if value is None:
|
|
269
|
+
continue
|
|
270
|
+
if index.has_value(value):
|
|
271
|
+
raise DuplicateKeyError(value)
|
|
272
|
+
index.insert(value, row_id)
|
|
273
|
+
|
|
274
|
+
return table
|
minidb_sql/types.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Type definitions for MiniDB."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ColumnType(Enum):
|
|
8
|
+
"""Supported column types in MiniDB."""
|
|
9
|
+
|
|
10
|
+
INTEGER = auto()
|
|
11
|
+
STRING = auto()
|
|
12
|
+
FLOAT = auto()
|
|
13
|
+
BOOLEAN = auto()
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_string(cls, type_str: str) -> 'ColumnType':
|
|
17
|
+
"""Convert a string representation to ColumnType."""
|
|
18
|
+
type_map = {
|
|
19
|
+
'INTEGER': cls.INTEGER,
|
|
20
|
+
'INT': cls.INTEGER,
|
|
21
|
+
'STRING': cls.STRING,
|
|
22
|
+
'STR': cls.STRING,
|
|
23
|
+
'TEXT': cls.STRING,
|
|
24
|
+
'VARCHAR': cls.STRING,
|
|
25
|
+
'FLOAT': cls.FLOAT,
|
|
26
|
+
'DOUBLE': cls.FLOAT,
|
|
27
|
+
'REAL': cls.FLOAT,
|
|
28
|
+
'BOOLEAN': cls.BOOLEAN,
|
|
29
|
+
'BOOL': cls.BOOLEAN,
|
|
30
|
+
}
|
|
31
|
+
upper_str = type_str.upper()
|
|
32
|
+
if upper_str not in type_map:
|
|
33
|
+
raise ValueError(f'Unknown column type: {type_str}')
|
|
34
|
+
return type_map[upper_str]
|
|
35
|
+
|
|
36
|
+
def to_string(self) -> str:
|
|
37
|
+
"""Convert ColumnType to string representation."""
|
|
38
|
+
return self.name
|
|
39
|
+
|
|
40
|
+
def validate(self, value: Any) -> bool:
|
|
41
|
+
"""Check if a value is valid for this column type."""
|
|
42
|
+
if value is None:
|
|
43
|
+
return True # NULL values handled by nullable constraint
|
|
44
|
+
|
|
45
|
+
type_validators = {
|
|
46
|
+
ColumnType.INTEGER: lambda v: isinstance(v, int) and not isinstance(v, bool),
|
|
47
|
+
ColumnType.STRING: lambda v: isinstance(v, str),
|
|
48
|
+
ColumnType.FLOAT: lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
49
|
+
ColumnType.BOOLEAN: lambda v: isinstance(v, bool),
|
|
50
|
+
}
|
|
51
|
+
return type_validators[self](value)
|
|
52
|
+
|
|
53
|
+
def cast(self, value: Any) -> Any:
|
|
54
|
+
"""Attempt to cast a value to this type."""
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
if self == ColumnType.INTEGER:
|
|
60
|
+
if isinstance(value, bool):
|
|
61
|
+
raise TypeError(f'Cannot cast {value!r} to INTEGER')
|
|
62
|
+
if isinstance(value, float):
|
|
63
|
+
if not value.is_integer():
|
|
64
|
+
raise TypeError(f'Cannot cast {value!r} to INTEGER')
|
|
65
|
+
return int(value)
|
|
66
|
+
if isinstance(value, int):
|
|
67
|
+
return value
|
|
68
|
+
return int(value)
|
|
69
|
+
elif self == ColumnType.STRING:
|
|
70
|
+
return str(value)
|
|
71
|
+
elif self == ColumnType.FLOAT:
|
|
72
|
+
if isinstance(value, bool):
|
|
73
|
+
raise TypeError(f'Cannot cast {value!r} to FLOAT')
|
|
74
|
+
return float(value)
|
|
75
|
+
elif self == ColumnType.BOOLEAN:
|
|
76
|
+
if isinstance(value, bool):
|
|
77
|
+
return value
|
|
78
|
+
if isinstance(value, str):
|
|
79
|
+
spelling = {
|
|
80
|
+
'true': True,
|
|
81
|
+
'false': False,
|
|
82
|
+
'1': True,
|
|
83
|
+
'0': False,
|
|
84
|
+
'yes': True,
|
|
85
|
+
'no': False,
|
|
86
|
+
}
|
|
87
|
+
if value.lower() not in spelling:
|
|
88
|
+
raise TypeError(f'Cannot cast {value!r} to BOOLEAN')
|
|
89
|
+
return spelling[value.lower()]
|
|
90
|
+
raise TypeError(f'Cannot cast {value!r} to BOOLEAN')
|
|
91
|
+
except (ValueError, TypeError) as e:
|
|
92
|
+
raise TypeError(f'Cannot cast {value!r} to {self.name}') from e
|
|
93
|
+
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class TokenType(Enum):
|
|
98
|
+
"""Token types for the SQL lexer."""
|
|
99
|
+
|
|
100
|
+
# Keywords
|
|
101
|
+
SELECT = auto()
|
|
102
|
+
FROM = auto()
|
|
103
|
+
WHERE = auto()
|
|
104
|
+
INSERT = auto()
|
|
105
|
+
INTO = auto()
|
|
106
|
+
VALUES = auto()
|
|
107
|
+
UPDATE = auto()
|
|
108
|
+
SET = auto()
|
|
109
|
+
DELETE = auto()
|
|
110
|
+
CREATE = auto()
|
|
111
|
+
TABLE = auto()
|
|
112
|
+
DROP = auto()
|
|
113
|
+
ORDER = auto()
|
|
114
|
+
BY = auto()
|
|
115
|
+
GROUP = auto()
|
|
116
|
+
JOIN = auto()
|
|
117
|
+
INNER = auto()
|
|
118
|
+
LEFT = auto()
|
|
119
|
+
RIGHT = auto()
|
|
120
|
+
ON = auto()
|
|
121
|
+
AND = auto()
|
|
122
|
+
OR = auto()
|
|
123
|
+
NOT = auto()
|
|
124
|
+
IN = auto()
|
|
125
|
+
LIKE = auto()
|
|
126
|
+
ASC = auto()
|
|
127
|
+
DESC = auto()
|
|
128
|
+
PRIMARY = auto()
|
|
129
|
+
KEY = auto()
|
|
130
|
+
NULL = auto()
|
|
131
|
+
NOT_NULL = auto()
|
|
132
|
+
IS = auto()
|
|
133
|
+
LIMIT = auto()
|
|
134
|
+
AS = auto()
|
|
135
|
+
DISTINCT = auto()
|
|
136
|
+
HAVING = auto()
|
|
137
|
+
CASE = auto()
|
|
138
|
+
WHEN = auto()
|
|
139
|
+
THEN = auto()
|
|
140
|
+
ELSE = auto()
|
|
141
|
+
END = auto()
|
|
142
|
+
BEGIN = auto()
|
|
143
|
+
COMMIT = auto()
|
|
144
|
+
ROLLBACK = auto()
|
|
145
|
+
|
|
146
|
+
# Literals
|
|
147
|
+
IDENTIFIER = auto()
|
|
148
|
+
STRING_LITERAL = auto()
|
|
149
|
+
INTEGER_LITERAL = auto()
|
|
150
|
+
FLOAT_LITERAL = auto()
|
|
151
|
+
BOOLEAN_LITERAL = auto()
|
|
152
|
+
|
|
153
|
+
# Operators
|
|
154
|
+
EQUALS = auto()
|
|
155
|
+
NOT_EQUALS = auto()
|
|
156
|
+
GREATER = auto()
|
|
157
|
+
GREATER_EQUAL = auto()
|
|
158
|
+
LESS = auto()
|
|
159
|
+
LESS_EQUAL = auto()
|
|
160
|
+
|
|
161
|
+
# Punctuation
|
|
162
|
+
COMMA = auto()
|
|
163
|
+
DOT = auto()
|
|
164
|
+
LPAREN = auto()
|
|
165
|
+
RPAREN = auto()
|
|
166
|
+
SEMICOLON = auto()
|
|
167
|
+
STAR = auto()
|
|
168
|
+
PLACEHOLDER = auto()
|
|
169
|
+
|
|
170
|
+
# Special
|
|
171
|
+
EOF = auto()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class QueryType(Enum):
|
|
175
|
+
"""Types of SQL queries."""
|
|
176
|
+
|
|
177
|
+
SELECT = auto()
|
|
178
|
+
INSERT = auto()
|
|
179
|
+
UPDATE = auto()
|
|
180
|
+
DELETE = auto()
|
|
181
|
+
CREATE_TABLE = auto()
|
|
182
|
+
DROP_TABLE = auto()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Type alias for row data
|
|
186
|
+
Row = dict[str, Any]
|
|
187
|
+
|
|
188
|
+
# Type alias for query result
|
|
189
|
+
QueryResult = list[Row] | int | None
|