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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Persistence layer for MiniDB."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .errors import FileReadError, FileWriteError, VersionMismatchError
|
|
7
|
+
from .table import Table
|
|
8
|
+
|
|
9
|
+
CURRENT_VERSION = '1.0'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _serialize(tables: dict[str, Table], filepath: str) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Serialize tables to a JSON file.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
tables: Mapping of table names to Table objects
|
|
18
|
+
filepath: Path to the output file
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
FileWriteError: If the file cannot be written
|
|
22
|
+
"""
|
|
23
|
+
data: dict[str, Any] = {'version': CURRENT_VERSION, 'tables': {}}
|
|
24
|
+
|
|
25
|
+
for table_name, table in tables.items():
|
|
26
|
+
data['tables'][table_name] = table.to_dict()
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
with open(filepath, 'w', encoding='utf-8') as f:
|
|
30
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
31
|
+
except OSError as e:
|
|
32
|
+
raise FileWriteError(filepath, str(e)) from e
|
|
33
|
+
except (TypeError, ValueError) as e:
|
|
34
|
+
raise FileWriteError(filepath, f'Serialization error: {e}') from e
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _deserialize(filepath: str) -> dict[str, Table]:
|
|
38
|
+
"""
|
|
39
|
+
Deserialize tables from a JSON file.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
filepath: Path to the input file
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Mapping of table names to Table objects
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
FileReadError: If the file cannot be read
|
|
49
|
+
VersionMismatchError: If the file version is incompatible
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
with open(filepath, encoding='utf-8') as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
except FileNotFoundError as e:
|
|
55
|
+
raise FileReadError(filepath, 'File not found') from e
|
|
56
|
+
except OSError as e:
|
|
57
|
+
raise FileReadError(filepath, str(e)) from e
|
|
58
|
+
except UnicodeDecodeError as e:
|
|
59
|
+
raise FileReadError(filepath, f'File is not UTF-8: {e}') from e
|
|
60
|
+
except json.JSONDecodeError as e:
|
|
61
|
+
raise FileReadError(filepath, f'Invalid JSON: {e}') from e
|
|
62
|
+
|
|
63
|
+
# Check version
|
|
64
|
+
version = data.get('version', 'unknown')
|
|
65
|
+
if version != CURRENT_VERSION:
|
|
66
|
+
raise VersionMismatchError(version, CURRENT_VERSION)
|
|
67
|
+
|
|
68
|
+
tables: dict[str, Table] = {}
|
|
69
|
+
for table_name, table_data in data.get('tables', {}).items():
|
|
70
|
+
tables[table_name] = Table.from_dict(table_data)
|
|
71
|
+
|
|
72
|
+
return tables
|
minidb_sql/planner.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Query planner for MiniDB."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
from .parser import Condition, SelectQuery, WhereClause
|
|
7
|
+
from .table import Table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScanType(Enum):
|
|
11
|
+
"""Types of table scans."""
|
|
12
|
+
|
|
13
|
+
TABLE_SCAN = 'table_scan'
|
|
14
|
+
INDEX_SCAN = 'index_scan'
|
|
15
|
+
INDEX_RANGE_SCAN = 'index_range_scan'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class QueryPlan:
|
|
20
|
+
"""Represents a query execution plan."""
|
|
21
|
+
|
|
22
|
+
scan_type: ScanType
|
|
23
|
+
index_column: str | None = None
|
|
24
|
+
estimated_rows: int = 0
|
|
25
|
+
use_index_for: str | None = None # The specific condition using index
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
if self.scan_type == ScanType.TABLE_SCAN:
|
|
29
|
+
return f'QueryPlan(table_scan, estimated_rows={self.estimated_rows})'
|
|
30
|
+
elif self.scan_type == ScanType.INDEX_SCAN:
|
|
31
|
+
return f"QueryPlan(index_scan on '{self.index_column}', estimated_rows={self.estimated_rows})"
|
|
32
|
+
else:
|
|
33
|
+
return f"QueryPlan(index_range_scan on '{self.index_column}', estimated_rows={self.estimated_rows})"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class QueryPlanner:
|
|
37
|
+
"""
|
|
38
|
+
Analyzes queries and determines the optimal execution strategy.
|
|
39
|
+
|
|
40
|
+
Decides between:
|
|
41
|
+
- Full table scan
|
|
42
|
+
- Index lookup (equality)
|
|
43
|
+
- Index range scan (comparisons)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, table: Table):
|
|
47
|
+
self.table = table
|
|
48
|
+
|
|
49
|
+
def plan_select(self, query: SelectQuery) -> QueryPlan:
|
|
50
|
+
"""
|
|
51
|
+
Create an execution plan for a SELECT query.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
query: The parsed SELECT query
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A QueryPlan with the chosen execution strategy
|
|
58
|
+
"""
|
|
59
|
+
total_rows = self.table.row_count
|
|
60
|
+
|
|
61
|
+
# No WHERE clause - must do table scan
|
|
62
|
+
if not query.where:
|
|
63
|
+
return QueryPlan(scan_type=ScanType.TABLE_SCAN, estimated_rows=total_rows)
|
|
64
|
+
|
|
65
|
+
# Check if we can use an index
|
|
66
|
+
index_condition = self._find_index_condition(query.where)
|
|
67
|
+
|
|
68
|
+
if index_condition:
|
|
69
|
+
column, operator, value = index_condition
|
|
70
|
+
|
|
71
|
+
# Equality on indexed column - use index scan
|
|
72
|
+
if operator == '=':
|
|
73
|
+
# Estimate: usually 1 row for PK, but could be more
|
|
74
|
+
estimated = self._estimate_equality_rows(column, value)
|
|
75
|
+
return QueryPlan(
|
|
76
|
+
scan_type=ScanType.INDEX_SCAN,
|
|
77
|
+
index_column=column,
|
|
78
|
+
estimated_rows=estimated,
|
|
79
|
+
use_index_for=f'{column} = {value!r}',
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Range comparison - use index range scan
|
|
83
|
+
elif operator in ('<', '<=', '>', '>='):
|
|
84
|
+
estimated = self._estimate_range_rows(operator, value, total_rows)
|
|
85
|
+
return QueryPlan(
|
|
86
|
+
scan_type=ScanType.INDEX_RANGE_SCAN,
|
|
87
|
+
index_column=column,
|
|
88
|
+
estimated_rows=estimated,
|
|
89
|
+
use_index_for=f'{column} {operator} {value!r}',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# No usable index - table scan
|
|
93
|
+
return QueryPlan(scan_type=ScanType.TABLE_SCAN, estimated_rows=total_rows)
|
|
94
|
+
|
|
95
|
+
def _find_index_condition(self, where: WhereClause) -> tuple | None:
|
|
96
|
+
"""
|
|
97
|
+
Find a condition that can use an index.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Tuple of (column, operator, value) or None
|
|
101
|
+
"""
|
|
102
|
+
# OR is not one index probe. Parentheses keep each AND conjunct nested.
|
|
103
|
+
for cond in where.and_conjuncts():
|
|
104
|
+
result = self._check_condition_for_index(cond)
|
|
105
|
+
if result:
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _check_condition_for_index(self, condition) -> tuple | None:
|
|
111
|
+
"""Check if a condition can use an index."""
|
|
112
|
+
if isinstance(condition, Condition):
|
|
113
|
+
if condition.negated:
|
|
114
|
+
return None
|
|
115
|
+
column = condition.column
|
|
116
|
+
operator = condition.operator
|
|
117
|
+
value = condition.value
|
|
118
|
+
|
|
119
|
+
# Check if column has an index
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
if self.table.has_index(column) and operator in ('=', '<', '<=', '>', '>='):
|
|
123
|
+
return (column, operator, value)
|
|
124
|
+
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def _estimate_equality_rows(self, column: str, value) -> int:
|
|
128
|
+
"""Estimate rows returned by an equality lookup."""
|
|
129
|
+
index = self.table.get_index(column)
|
|
130
|
+
if index:
|
|
131
|
+
return len(index.lookup(value))
|
|
132
|
+
return 1 # Assume unique for PK
|
|
133
|
+
|
|
134
|
+
def _estimate_range_rows(self, op: str, value, total_rows: int) -> int:
|
|
135
|
+
"""
|
|
136
|
+
Estimate rows returned by a range scan.
|
|
137
|
+
|
|
138
|
+
Simple heuristic: assume uniform distribution.
|
|
139
|
+
"""
|
|
140
|
+
# For simplicity, estimate 30% of rows for range queries
|
|
141
|
+
# A real database would use statistics
|
|
142
|
+
if op in ('<', '<='):
|
|
143
|
+
return max(1, total_rows // 3)
|
|
144
|
+
else: # '>', '>='
|
|
145
|
+
return max(1, total_rows // 3)
|
|
146
|
+
|
|
147
|
+
def get_row_ids_for_condition(self, condition: Condition) -> set[int] | None:
|
|
148
|
+
"""
|
|
149
|
+
Get row IDs that match a condition using an index.
|
|
150
|
+
|
|
151
|
+
Returns None if index cannot be used.
|
|
152
|
+
"""
|
|
153
|
+
column = condition.column
|
|
154
|
+
operator = condition.operator
|
|
155
|
+
value = condition.value
|
|
156
|
+
|
|
157
|
+
if not self.table.has_index(column):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
index = self.table.get_index(column)
|
|
161
|
+
|
|
162
|
+
if operator == '=':
|
|
163
|
+
return index.lookup(value)
|
|
164
|
+
elif operator in ('<', '<=', '>', '>='):
|
|
165
|
+
return index.range_lookup(operator, value)
|
|
166
|
+
|
|
167
|
+
return None
|