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