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.
@@ -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