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/__init__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MiniDB - A miniature in-memory database with SQL-like query support.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- CREATE TABLE with typed columns (INTEGER, STRING, FLOAT, BOOLEAN)
|
|
6
|
+
- INSERT, SELECT, UPDATE, DELETE operations
|
|
7
|
+
- WHERE clause with AND/OR, comparisons (=, >, <, LIKE, IN)
|
|
8
|
+
- ORDER BY for sorting
|
|
9
|
+
- GROUP BY with aggregations (COUNT, SUM, AVG, MIN, MAX)
|
|
10
|
+
- JOIN between tables
|
|
11
|
+
- Automatic indexing on primary keys
|
|
12
|
+
- Query planner (index scan vs table scan)
|
|
13
|
+
- Persistence to JSON files
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
from minidb_sql import MiniDB, Column, ColumnType
|
|
17
|
+
|
|
18
|
+
# Create database
|
|
19
|
+
db = MiniDB()
|
|
20
|
+
|
|
21
|
+
# Create table programmatically
|
|
22
|
+
db.create_table('users', [
|
|
23
|
+
Column('id', ColumnType.INTEGER, primary_key=True),
|
|
24
|
+
Column('name', ColumnType.STRING),
|
|
25
|
+
Column('age', ColumnType.INTEGER),
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
# Or use SQL
|
|
29
|
+
db.execute('''
|
|
30
|
+
CREATE TABLE orders (
|
|
31
|
+
id INTEGER PRIMARY KEY,
|
|
32
|
+
user_id INTEGER,
|
|
33
|
+
total FLOAT
|
|
34
|
+
)
|
|
35
|
+
''')
|
|
36
|
+
|
|
37
|
+
# Insert data
|
|
38
|
+
db.execute("INSERT INTO users (id, name, age) VALUES (1, 'Alice', 30)")
|
|
39
|
+
db.execute("INSERT INTO orders (id, user_id, total) VALUES (1, 1, 99.99)")
|
|
40
|
+
|
|
41
|
+
# Query with SQL
|
|
42
|
+
results = db.query('''
|
|
43
|
+
SELECT users.name, orders.total
|
|
44
|
+
FROM users
|
|
45
|
+
JOIN orders ON users.id = orders.user_id
|
|
46
|
+
WHERE users.age > 25
|
|
47
|
+
''')
|
|
48
|
+
|
|
49
|
+
# Save and load
|
|
50
|
+
db.save('mydb.json')
|
|
51
|
+
db = MiniDB.load('mydb.json')
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from .column import Column, Schema
|
|
55
|
+
from .database import MiniDB
|
|
56
|
+
from .errors import (
|
|
57
|
+
AmbiguousColumnError,
|
|
58
|
+
ColumnError,
|
|
59
|
+
ColumnExistsError,
|
|
60
|
+
ColumnNotFoundError,
|
|
61
|
+
ConstraintError,
|
|
62
|
+
DuplicateKeyError,
|
|
63
|
+
FileReadError,
|
|
64
|
+
FileWriteError,
|
|
65
|
+
InvalidQueryError,
|
|
66
|
+
MiniDBError,
|
|
67
|
+
NullValueError,
|
|
68
|
+
ParseError,
|
|
69
|
+
PersistenceError,
|
|
70
|
+
PrimaryKeyError,
|
|
71
|
+
QueryError,
|
|
72
|
+
SyntaxError_,
|
|
73
|
+
TableError,
|
|
74
|
+
TableExistsError,
|
|
75
|
+
TableNotFoundError,
|
|
76
|
+
TypeError_,
|
|
77
|
+
TypeMismatchError,
|
|
78
|
+
VersionMismatchError,
|
|
79
|
+
)
|
|
80
|
+
from .index import HashIndex, IndexManager
|
|
81
|
+
from .parser import (
|
|
82
|
+
BeginQuery,
|
|
83
|
+
CommitQuery,
|
|
84
|
+
CreateTableQuery,
|
|
85
|
+
DeleteQuery,
|
|
86
|
+
DropTableQuery,
|
|
87
|
+
InsertQuery,
|
|
88
|
+
Lexer,
|
|
89
|
+
Parser,
|
|
90
|
+
RollbackQuery,
|
|
91
|
+
SelectQuery,
|
|
92
|
+
Token,
|
|
93
|
+
UpdateQuery,
|
|
94
|
+
parse_sql,
|
|
95
|
+
)
|
|
96
|
+
from .persistence import _deserialize as load_database
|
|
97
|
+
from .persistence import _serialize as save_database
|
|
98
|
+
from .planner import QueryPlan, QueryPlanner, ScanType
|
|
99
|
+
from .query import QueryExecutor
|
|
100
|
+
from .table import Table
|
|
101
|
+
from .types import ColumnType, QueryResult, QueryType, Row, TokenType
|
|
102
|
+
|
|
103
|
+
__all__ = [
|
|
104
|
+
'AmbiguousColumnError',
|
|
105
|
+
'BeginQuery',
|
|
106
|
+
# Schema
|
|
107
|
+
'Column',
|
|
108
|
+
'ColumnError',
|
|
109
|
+
'ColumnExistsError',
|
|
110
|
+
'ColumnNotFoundError',
|
|
111
|
+
# Types
|
|
112
|
+
'ColumnType',
|
|
113
|
+
'CommitQuery',
|
|
114
|
+
'ConstraintError',
|
|
115
|
+
'CreateTableQuery',
|
|
116
|
+
'DeleteQuery',
|
|
117
|
+
'DropTableQuery',
|
|
118
|
+
'DuplicateKeyError',
|
|
119
|
+
'FileReadError',
|
|
120
|
+
'FileWriteError',
|
|
121
|
+
# Indexing
|
|
122
|
+
'HashIndex',
|
|
123
|
+
'IndexManager',
|
|
124
|
+
'InsertQuery',
|
|
125
|
+
'InvalidQueryError',
|
|
126
|
+
'Lexer',
|
|
127
|
+
# Main API
|
|
128
|
+
'MiniDB',
|
|
129
|
+
# Errors
|
|
130
|
+
'MiniDBError',
|
|
131
|
+
'NullValueError',
|
|
132
|
+
'ParseError',
|
|
133
|
+
'Parser',
|
|
134
|
+
'PersistenceError',
|
|
135
|
+
'PrimaryKeyError',
|
|
136
|
+
'QueryError',
|
|
137
|
+
# Query execution
|
|
138
|
+
'QueryExecutor',
|
|
139
|
+
'QueryPlan',
|
|
140
|
+
# Query planning
|
|
141
|
+
'QueryPlanner',
|
|
142
|
+
'QueryResult',
|
|
143
|
+
'QueryType',
|
|
144
|
+
'RollbackQuery',
|
|
145
|
+
'Row',
|
|
146
|
+
'ScanType',
|
|
147
|
+
'Schema',
|
|
148
|
+
'SelectQuery',
|
|
149
|
+
'SyntaxError_',
|
|
150
|
+
# Storage
|
|
151
|
+
'Table',
|
|
152
|
+
'TableError',
|
|
153
|
+
'TableExistsError',
|
|
154
|
+
'TableNotFoundError',
|
|
155
|
+
'Token',
|
|
156
|
+
'TokenType',
|
|
157
|
+
'TypeError_',
|
|
158
|
+
'TypeMismatchError',
|
|
159
|
+
'UpdateQuery',
|
|
160
|
+
'VersionMismatchError',
|
|
161
|
+
'load_database',
|
|
162
|
+
# Parsing
|
|
163
|
+
'parse_sql',
|
|
164
|
+
# Persistence
|
|
165
|
+
'save_database',
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
__version__ = '1.3.0' # x-release-please-version
|
minidb_sql/column.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Column definition and schema management for MiniDB."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .errors import ColumnError, NullValueError, TypeMismatchError
|
|
7
|
+
from .types import ColumnType
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Column:
|
|
12
|
+
"""Represents a column definition in a table."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
type: ColumnType
|
|
16
|
+
primary_key: bool = False
|
|
17
|
+
nullable: bool = True
|
|
18
|
+
default: Any = None
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
"""Validate column definition after initialization."""
|
|
22
|
+
if self.primary_key and not self.nullable:
|
|
23
|
+
# Primary keys are implicitly NOT NULL
|
|
24
|
+
pass
|
|
25
|
+
if self.primary_key:
|
|
26
|
+
self.nullable = False
|
|
27
|
+
if self.default is not None:
|
|
28
|
+
# Same cast as INSERT. A raw default skips INTEGER's int() rules.
|
|
29
|
+
self.default = self.validate(self.default)
|
|
30
|
+
|
|
31
|
+
def validate(self, value: Any) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Validate and optionally cast a value for this column.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
value: The value to validate
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The validated (and possibly cast) value
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
NullValueError: If value is None and column is not nullable
|
|
43
|
+
TypeMismatchError: If value cannot be cast to the column type
|
|
44
|
+
"""
|
|
45
|
+
if value is None:
|
|
46
|
+
if not self.nullable:
|
|
47
|
+
raise NullValueError(self.name)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
# Try to cast the value to the correct type
|
|
51
|
+
try:
|
|
52
|
+
cast_value = self.type.cast(value)
|
|
53
|
+
except (ValueError, TypeError) as e:
|
|
54
|
+
raise TypeMismatchError(self.name, self.type.name, type(value).__name__) from e
|
|
55
|
+
|
|
56
|
+
return cast_value
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
"""Serialize column to a dictionary."""
|
|
60
|
+
return {
|
|
61
|
+
'name': self.name,
|
|
62
|
+
'type': self.type.name,
|
|
63
|
+
'primary_key': self.primary_key,
|
|
64
|
+
'nullable': self.nullable,
|
|
65
|
+
'default': self.default,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, data: dict) -> 'Column':
|
|
70
|
+
"""Deserialize column from a dictionary."""
|
|
71
|
+
return cls(
|
|
72
|
+
name=data['name'],
|
|
73
|
+
type=ColumnType[data['type']],
|
|
74
|
+
primary_key=data.get('primary_key', False),
|
|
75
|
+
nullable=data.get('nullable', True),
|
|
76
|
+
default=data.get('default'),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Schema:
|
|
82
|
+
"""Represents a table schema with multiple columns."""
|
|
83
|
+
|
|
84
|
+
columns: list[Column] = field(default_factory=list)
|
|
85
|
+
_column_map: dict[str, Column] = field(default_factory=dict, repr=False)
|
|
86
|
+
_primary_key: str | None = field(default=None, repr=False)
|
|
87
|
+
|
|
88
|
+
def __post_init__(self):
|
|
89
|
+
"""Build internal structures after initialization."""
|
|
90
|
+
self._rebuild_indexes()
|
|
91
|
+
|
|
92
|
+
def _rebuild_indexes(self):
|
|
93
|
+
"""Rebuild internal indexes for fast lookups."""
|
|
94
|
+
seen: set[str] = set()
|
|
95
|
+
for col in self.columns:
|
|
96
|
+
if col.name in seen:
|
|
97
|
+
# The map would keep the last column and drop the earlier type.
|
|
98
|
+
raise ColumnError(f"Duplicate column name '{col.name}'")
|
|
99
|
+
seen.add(col.name)
|
|
100
|
+
self._column_map = {col.name: col for col in self.columns}
|
|
101
|
+
primary_keys = [col.name for col in self.columns if col.primary_key]
|
|
102
|
+
if len(primary_keys) > 1:
|
|
103
|
+
quoted = ', '.join(f"'{name}'" for name in primary_keys)
|
|
104
|
+
raise ColumnError(f'Multiple PRIMARY KEY columns: {quoted}')
|
|
105
|
+
self._primary_key = primary_keys[0] if primary_keys else None
|
|
106
|
+
|
|
107
|
+
def add_column(self, column: Column) -> None:
|
|
108
|
+
"""Add a column to the schema."""
|
|
109
|
+
if column.name in self._column_map:
|
|
110
|
+
raise ValueError(f"Column '{column.name}' already exists")
|
|
111
|
+
if column.primary_key and self._primary_key is not None:
|
|
112
|
+
raise ColumnError(f"Multiple PRIMARY KEY columns: '{self._primary_key}', '{column.name}'")
|
|
113
|
+
self.columns.append(column)
|
|
114
|
+
self._column_map[column.name] = column
|
|
115
|
+
if column.primary_key:
|
|
116
|
+
self._primary_key = column.name
|
|
117
|
+
|
|
118
|
+
def get_column(self, name: str) -> Column | None:
|
|
119
|
+
"""Get a column by name."""
|
|
120
|
+
return self._column_map.get(name)
|
|
121
|
+
|
|
122
|
+
def has_column(self, name: str) -> bool:
|
|
123
|
+
"""Check if a column exists."""
|
|
124
|
+
return name in self._column_map
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def primary_key(self) -> str | None:
|
|
128
|
+
"""Get the primary key column name, if any."""
|
|
129
|
+
return self._primary_key
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def column_names(self) -> list[str]:
|
|
133
|
+
"""Get list of all column names."""
|
|
134
|
+
return [col.name for col in self.columns]
|
|
135
|
+
|
|
136
|
+
def validate_row(self, row: dict, allow_missing: bool = False) -> dict:
|
|
137
|
+
"""
|
|
138
|
+
Validate a row against the schema.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
row: The row data to validate
|
|
142
|
+
allow_missing: If True, missing columns get default values
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
A validated row with all columns present
|
|
146
|
+
"""
|
|
147
|
+
validated = {}
|
|
148
|
+
for col in self.columns:
|
|
149
|
+
if col.name in row:
|
|
150
|
+
validated[col.name] = col.validate(row[col.name])
|
|
151
|
+
elif allow_missing:
|
|
152
|
+
if col.default is not None:
|
|
153
|
+
validated[col.name] = col.validate(col.default)
|
|
154
|
+
elif col.nullable:
|
|
155
|
+
validated[col.name] = None
|
|
156
|
+
else:
|
|
157
|
+
raise NullValueError(col.name)
|
|
158
|
+
else:
|
|
159
|
+
raise ValueError(f'Missing column: {col.name}')
|
|
160
|
+
return validated
|
|
161
|
+
|
|
162
|
+
def to_dict(self) -> dict:
|
|
163
|
+
"""Serialize schema to a dictionary."""
|
|
164
|
+
return {'columns': [col.to_dict() for col in self.columns]}
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_dict(cls, data: dict) -> 'Schema':
|
|
168
|
+
"""Deserialize schema from a dictionary."""
|
|
169
|
+
columns = [Column.from_dict(col_data) for col_data in data['columns']]
|
|
170
|
+
return cls(columns=columns)
|
minidb_sql/database.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Main database API for MiniDB."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .column import Column, Schema
|
|
8
|
+
from .errors import InvalidQueryError, MiniDBError, TableExistsError, TableNotFoundError
|
|
9
|
+
from .parser import (
|
|
10
|
+
BeginQuery,
|
|
11
|
+
CommitQuery,
|
|
12
|
+
CreateTableQuery,
|
|
13
|
+
DropTableQuery,
|
|
14
|
+
RollbackQuery,
|
|
15
|
+
SelectQuery,
|
|
16
|
+
bind_params,
|
|
17
|
+
parse_sql,
|
|
18
|
+
)
|
|
19
|
+
from .persistence import _deserialize, _serialize
|
|
20
|
+
from .planner import QueryPlan, QueryPlanner, ScanType
|
|
21
|
+
from .query import QueryExecutor
|
|
22
|
+
from .table import Table
|
|
23
|
+
from .types import QueryResult, Row
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MiniDB:
|
|
27
|
+
"""
|
|
28
|
+
MiniDB - A miniature in-memory database with SQL-like query support.
|
|
29
|
+
|
|
30
|
+
Example usage:
|
|
31
|
+
db = MiniDB()
|
|
32
|
+
|
|
33
|
+
# Create a table
|
|
34
|
+
db.execute('''
|
|
35
|
+
CREATE TABLE users (
|
|
36
|
+
id INTEGER PRIMARY KEY,
|
|
37
|
+
name STRING,
|
|
38
|
+
age INTEGER,
|
|
39
|
+
salary FLOAT,
|
|
40
|
+
active BOOLEAN
|
|
41
|
+
)
|
|
42
|
+
''')
|
|
43
|
+
|
|
44
|
+
# Insert data
|
|
45
|
+
db.execute("INSERT INTO users (id, name, age, salary, active) VALUES (1, 'Alice', 30, 50000.0, true)")
|
|
46
|
+
|
|
47
|
+
# Query data
|
|
48
|
+
results = db.query("SELECT * FROM users WHERE age > 25")
|
|
49
|
+
|
|
50
|
+
# Save to file
|
|
51
|
+
db.save('database.json')
|
|
52
|
+
|
|
53
|
+
# Load from file
|
|
54
|
+
db = MiniDB.load('database.json')
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
"""Initialize an empty database."""
|
|
59
|
+
self._tables: dict[str, Table] = {}
|
|
60
|
+
self._executor: QueryExecutor | None = None
|
|
61
|
+
self._txn_snapshot: dict[str, Table] | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def tables(self) -> list[str]:
|
|
65
|
+
"""Get list of table names."""
|
|
66
|
+
return list(self._tables.keys())
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def executor(self) -> QueryExecutor:
|
|
70
|
+
"""Get or create the query executor."""
|
|
71
|
+
if self._executor is None:
|
|
72
|
+
self._executor = QueryExecutor(self._tables)
|
|
73
|
+
return self._executor
|
|
74
|
+
|
|
75
|
+
def create_table(self, name: str, columns: list[Column]) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Programmatically create a table.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
name: Table name
|
|
81
|
+
columns: List of Column objects
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
TableExistsError: If table already exists
|
|
85
|
+
ColumnError: If more than one column is a primary key
|
|
86
|
+
"""
|
|
87
|
+
if name in self._tables:
|
|
88
|
+
raise TableExistsError(name)
|
|
89
|
+
|
|
90
|
+
schema = Schema(columns=columns)
|
|
91
|
+
table = Table(name=name, schema=schema)
|
|
92
|
+
self._tables[name] = table
|
|
93
|
+
|
|
94
|
+
# Reset executor to pick up new tables
|
|
95
|
+
self._executor = None
|
|
96
|
+
|
|
97
|
+
def drop_table(self, name: str) -> None:
|
|
98
|
+
"""
|
|
99
|
+
Drop a table.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
name: Table name
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
TableNotFoundError: If table doesn't exist
|
|
106
|
+
"""
|
|
107
|
+
if name not in self._tables:
|
|
108
|
+
raise TableNotFoundError(name)
|
|
109
|
+
|
|
110
|
+
del self._tables[name]
|
|
111
|
+
self._executor = None
|
|
112
|
+
|
|
113
|
+
def get_table(self, name: str) -> Table | None:
|
|
114
|
+
"""Get a table by name."""
|
|
115
|
+
return self._tables.get(name)
|
|
116
|
+
|
|
117
|
+
def execute(self, sql: str, params: Sequence[Any] | None = None) -> QueryResult:
|
|
118
|
+
"""
|
|
119
|
+
Execute a SQL statement.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
sql: SQL statement string
|
|
123
|
+
params: Values for ? placeholders, bound left-to-right after parse
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
- For SELECT: list of row dictionaries
|
|
127
|
+
- For INSERT: row ID (int)
|
|
128
|
+
- For UPDATE/DELETE: number of affected rows (int)
|
|
129
|
+
- For CREATE/DROP TABLE: None
|
|
130
|
+
- For BEGIN/COMMIT/ROLLBACK: None
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
MiniDBError: If query execution fails
|
|
134
|
+
"""
|
|
135
|
+
query = parse_sql(sql)
|
|
136
|
+
bind_params(query, params)
|
|
137
|
+
|
|
138
|
+
if isinstance(query, BeginQuery):
|
|
139
|
+
if self._txn_snapshot is not None:
|
|
140
|
+
raise InvalidQueryError('already in a transaction')
|
|
141
|
+
self._txn_snapshot = {
|
|
142
|
+
name: Table.from_dict(copy.deepcopy(table.to_dict())) for name, table in self._tables.items()
|
|
143
|
+
}
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if isinstance(query, CommitQuery):
|
|
147
|
+
if self._txn_snapshot is None:
|
|
148
|
+
raise InvalidQueryError('no transaction in progress')
|
|
149
|
+
self._txn_snapshot = None
|
|
150
|
+
self._executor = None
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
if isinstance(query, RollbackQuery):
|
|
154
|
+
if self._txn_snapshot is None:
|
|
155
|
+
raise InvalidQueryError('no transaction in progress')
|
|
156
|
+
self._tables = self._txn_snapshot
|
|
157
|
+
self._txn_snapshot = None
|
|
158
|
+
self._executor = None
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if isinstance(query, CreateTableQuery):
|
|
162
|
+
columns = [
|
|
163
|
+
Column(name=col.name, type=col.type, primary_key=col.primary_key, nullable=col.nullable)
|
|
164
|
+
for col in query.columns
|
|
165
|
+
]
|
|
166
|
+
self.create_table(query.table, columns)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
elif isinstance(query, DropTableQuery):
|
|
170
|
+
self.drop_table(query.table)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
else:
|
|
174
|
+
return self.executor.execute(query)
|
|
175
|
+
|
|
176
|
+
def query(self, sql: str, params: Sequence[Any] | None = None) -> list[Row]:
|
|
177
|
+
"""
|
|
178
|
+
Execute a SELECT query and return results.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
sql: SELECT statement
|
|
182
|
+
params: Values for ? placeholders, bound left-to-right after parse
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of row dictionaries
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
MiniDBError: If the statement is not SELECT or execution fails
|
|
189
|
+
"""
|
|
190
|
+
result = self.execute(sql, params)
|
|
191
|
+
if isinstance(result, list):
|
|
192
|
+
return result
|
|
193
|
+
raise MiniDBError('query() only runs SELECT; use execute() for INSERT, UPDATE, DELETE, CREATE, or DROP')
|
|
194
|
+
|
|
195
|
+
def explain(self, sql: str, params: Sequence[Any] | None = None) -> str:
|
|
196
|
+
"""
|
|
197
|
+
Return the query plan for a SELECT statement.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
sql: SELECT statement
|
|
201
|
+
params: Values for ? placeholders, bound before planning
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
String representation of the scan that runs
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
MiniDBError: If the statement is not SELECT
|
|
208
|
+
TableNotFoundError: If the FROM table does not exist
|
|
209
|
+
"""
|
|
210
|
+
query = parse_sql(sql)
|
|
211
|
+
bind_params(query, params)
|
|
212
|
+
if not isinstance(query, SelectQuery):
|
|
213
|
+
raise MiniDBError('explain() is SELECT-only')
|
|
214
|
+
if query.table not in self._tables:
|
|
215
|
+
raise TableNotFoundError(query.table)
|
|
216
|
+
# Joins nested-loop both sides. Do not report an index plan of the FROM table.
|
|
217
|
+
if query.joins:
|
|
218
|
+
estimated = self._tables[query.table].row_count
|
|
219
|
+
return str(QueryPlan(scan_type=ScanType.TABLE_SCAN, estimated_rows=estimated))
|
|
220
|
+
return str(QueryPlanner(self._tables[query.table]).plan_select(query))
|
|
221
|
+
|
|
222
|
+
def save(self, filepath: str) -> None:
|
|
223
|
+
"""
|
|
224
|
+
Save the database to a file.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
filepath: Path to the output file
|
|
228
|
+
"""
|
|
229
|
+
_serialize(self._tables, filepath)
|
|
230
|
+
|
|
231
|
+
@classmethod
|
|
232
|
+
def load(cls, filepath: str) -> 'MiniDB':
|
|
233
|
+
"""
|
|
234
|
+
Load a database from a file.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
filepath: Path to the input file
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Loaded database instance
|
|
241
|
+
"""
|
|
242
|
+
tables = _deserialize(filepath)
|
|
243
|
+
db = cls()
|
|
244
|
+
db._tables = tables
|
|
245
|
+
return db
|
|
246
|
+
|
|
247
|
+
def __repr__(self) -> str:
|
|
248
|
+
"""String representation of the database."""
|
|
249
|
+
table_info = ', '.join(f'{name}({table.row_count} rows)' for name, table in self._tables.items())
|
|
250
|
+
return f'MiniDB({table_info})'
|
|
251
|
+
|
|
252
|
+
def __len__(self) -> int:
|
|
253
|
+
"""Return number of tables."""
|
|
254
|
+
return len(self._tables)
|
|
255
|
+
|
|
256
|
+
def __contains__(self, table_name: str) -> bool:
|
|
257
|
+
"""Check if a table exists."""
|
|
258
|
+
return table_name in self._tables
|