diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f54a520..c681af0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Harden runner uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 diff --git a/.gitignore b/.gitignore index b06b909..bf3cb29 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ coverage.xml htmlcov/ .pytest_cache/ +.mypy_cache/ # IDE .vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e6b644d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing to MiniDB + +## Prerequisites + +- Python 3.11 or later +- No external dependencies for the library itself + +## Development Setup + +```bash +git clone https://github.com/SebTardif/MiniDB.git +cd MiniDB + +# Install dev tools +pip install ruff mypy pytest pytest-cov + +# Verify everything works +python -m pytest tests/ -v +python main.py +``` + +## Pre-Commit Checks + +Run these before submitting a PR (CI enforces all of them): + +```bash +# Lint +ruff check . + +# Format +ruff format --check . + +# Type check +mypy minidb/ + +# Tests +python -m pytest tests/ -v +``` + +## Commit Message Convention + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) +for automatic versioning via semantic-release. + +Allowed prefixes: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, +`test`, `build`, `ci`, `chore`, `revert`. + +Examples: +- `feat: add DISTINCT keyword support` +- `fix: handle NULL in ORDER BY correctly` +- `test: add edge case tests for JOIN` +- `docs: update SQL syntax reference` + +## Project Structure + +``` +minidb/ + database.py # MiniDB main class (facade) + parser.py # SQL lexer and parser + query.py # Query execution engine + table.py # Table storage and row management + column.py # Column and Schema definitions + index.py # Hash-based indexing + planner.py # Query planner (index vs table scan) + persistence.py # JSON serialization + types.py # Type definitions and enums + errors.py # Custom exception hierarchy +tests/ + test_*.py # Test files matching source modules +``` diff --git a/README.md b/README.md index b7d5f30..16284eb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A miniature in-memory database with SQL-like query support, built from scratch u - GROUP BY with aggregations - LIMIT clause - **Aggregations**: COUNT, SUM, AVG, MIN, MAX -- **JOINs**: INNER JOIN between tables +- **JOINs**: INNER JOIN and LEFT JOIN between tables - **Indexing**: Automatic hash-based indexing on primary keys - **Query Planner**: Chooses between index scans and table scans - **Persistence**: Save/load database to JSON files with versioning @@ -23,7 +23,7 @@ No installation required! MiniDB uses only Python standard library. ```bash # Just clone and use -git clone +git clone https://github.com/SebTardif/MiniDB.git cd MiniDB python -m pytest tests/ -v # Run tests python main.py # Run demo @@ -161,6 +161,11 @@ LIMIT 10 SELECT t1.col, t2.col FROM t1 JOIN t2 ON t1.id = t2.t1_id + +-- LEFT JOIN (includes unmatched left rows with NULLs) +SELECT t1.col, t2.col +FROM t1 +LEFT JOIN t2 ON t1.id = t2.t1_id ``` ### UPDATE @@ -194,7 +199,7 @@ minidb/ ## Test Suite -MiniDB includes a comprehensive test suite with 29+ test cases: +MiniDB includes a comprehensive test suite with 95+ test cases: ```bash # Run all tests @@ -211,7 +216,7 @@ python -m pytest tests/ -v --cov=minidb - **test_database.py**: Database lifecycle and table management - **test_crud.py**: INSERT, SELECT, UPDATE, DELETE operations -- **test_queries.py**: WHERE, ORDER BY, LIMIT +- **test_queries.py**: WHERE, ORDER BY, LIMIT, parser errors, type validation - **test_aggregations.py**: COUNT, SUM, AVG, MIN, MAX, GROUP BY - **test_joins.py**: JOIN operations - **test_index.py**: Indexing and query planning @@ -233,8 +238,7 @@ MiniDB is designed for small to medium datasets: - Single-threaded - No transactions - No foreign key constraints -- No NULL handling in some edge cases -- Limited JOIN support (INNER JOIN only) +- Limited JOIN support (INNER and LEFT JOIN only, no RIGHT JOIN execution) ## License @@ -242,7 +246,9 @@ MIT License - Use freely for any purpose. ## Contributing -Contributions welcome! Areas for improvement: +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. + +Areas for improvement: - B-tree indexes for range queries - LEFT/RIGHT OUTER JOIN diff --git a/main.py b/main.py index 0e753df..43cd0e6 100644 --- a/main.py +++ b/main.py @@ -12,10 +12,6 @@ - Persistence """ -import sys - -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import MiniDB diff --git a/minidb/query.py b/minidb/query.py index 43264c0..abf8b76 100644 --- a/minidb/query.py +++ b/minidb/query.py @@ -1,5 +1,6 @@ """Query execution engine for MiniDB.""" +import re from collections import defaultdict from typing import Any @@ -241,7 +242,6 @@ def _evaluate_condition(self, row: Row, condition: Condition) -> bool: def _match_like(self, value: str, pattern: str) -> bool: """Match a value against a LIKE pattern.""" - import re # Convert SQL LIKE pattern to regex # % matches any sequence, _ matches single character @@ -385,13 +385,8 @@ def sort_key(row): return keys # Sort with direction handling - sorted_rows = sorted(rows, key=sort_key) - - # Reverse if DESC - if order_by and order_by[0].direction == 'DESC': - sorted_rows = sorted(rows, key=sort_key, reverse=True) - - return sorted_rows + reverse = bool(order_by and order_by[0].direction == 'DESC') + return sorted(rows, key=sort_key, reverse=reverse) def execute_insert(self, query: InsertQuery) -> int: """Execute an INSERT query.""" diff --git a/pyproject.toml b/pyproject.toml index 43c3d13..f36ec33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Database", "Typing :: Typed", ] diff --git a/tests/test_aggregations.py b/tests/test_aggregations.py index 70b0ed5..0e84185 100644 --- a/tests/test_aggregations.py +++ b/tests/test_aggregations.py @@ -1,11 +1,7 @@ """Tests for aggregation functions.""" -import sys - import pytest -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, MiniDB diff --git a/tests/test_crud.py b/tests/test_crud.py index 6247a54..0a55bb7 100644 --- a/tests/test_crud.py +++ b/tests/test_crud.py @@ -1,11 +1,7 @@ """Tests for INSERT, SELECT, UPDATE, DELETE operations.""" -import sys - import pytest -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, DuplicateKeyError, MiniDB diff --git a/tests/test_database.py b/tests/test_database.py index 5626e57..dcfb1ee 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,11 +1,7 @@ """Tests for database lifecycle and table management.""" -import sys - import pytest -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, MiniDB, TableExistsError, TableNotFoundError @@ -99,3 +95,52 @@ def test_drop_table_not_found(self): with pytest.raises(TableNotFoundError): db.drop_table('nonexistent') + + def test_drop_table_sql(self): + """Test dropping a table using SQL.""" + db = MiniDB() + db.execute('CREATE TABLE temp (id INTEGER PRIMARY KEY, value STRING)') + assert 'temp' in db + db.execute('DROP TABLE temp') + assert 'temp' not in db + + def test_query_on_non_select(self): + """Test that query() raises MiniDBError for non-SELECT statements.""" + from minidb.errors import MiniDBError + + db = MiniDB() + db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, v STRING)') + with pytest.raises(MiniDBError, match='Query did not return rows'): + db.query("INSERT INTO t (id, v) VALUES (1, 'x')") + + def test_repr(self): + """Test __repr__ on MiniDB.""" + db = MiniDB() + assert repr(db) == 'MiniDB()' + db.execute('CREATE TABLE users (id INTEGER PRIMARY KEY)') + db.execute('INSERT INTO users (id) VALUES (1)') + assert 'users(1 rows)' in repr(db) + + def test_insert_into_nonexistent_table(self): + """Test INSERT into a table that does not exist raises error.""" + db = MiniDB() + with pytest.raises(TableNotFoundError): + db.execute('INSERT INTO ghost (id) VALUES (1)') + + def test_update_nonexistent_table(self): + """Test UPDATE on a table that does not exist raises error.""" + db = MiniDB() + with pytest.raises(TableNotFoundError): + db.execute('UPDATE ghost SET id = 1') + + def test_delete_from_nonexistent_table(self): + """Test DELETE from a table that does not exist raises error.""" + db = MiniDB() + with pytest.raises(TableNotFoundError): + db.execute('DELETE FROM ghost') + + def test_select_from_nonexistent_table(self): + """Test SELECT from a table that does not exist raises error.""" + db = MiniDB() + with pytest.raises(TableNotFoundError): + db.query('SELECT * FROM ghost') diff --git a/tests/test_index.py b/tests/test_index.py index 2d189b2..6c6958e 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,9 +1,5 @@ """Tests for indexing and query planning.""" -import sys - -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, HashIndex, MiniDB from minidb.planner import QueryPlanner, ScanType diff --git a/tests/test_joins.py b/tests/test_joins.py index 56a0dae..6e3ef54 100644 --- a/tests/test_joins.py +++ b/tests/test_joins.py @@ -1,11 +1,7 @@ """Tests for JOIN operations.""" -import sys - import pytest -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, MiniDB diff --git a/tests/test_performance.py b/tests/test_performance.py index 4f59ca8..1f0375b 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -1,12 +1,9 @@ """Tests for performance with large datasets.""" -import sys import time import pytest -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, MiniDB diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 1103017..6c9376e 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -1,13 +1,10 @@ """Tests for persistence functionality.""" import os -import sys import tempfile import pytest -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, MiniDB @@ -170,6 +167,46 @@ def test_persistence_file_not_found(self): with pytest.raises(FileReadError): MiniDB.load('/nonexistent/path/to/file.json') + def test_persistence_version_mismatch(self): + """Test loading a file with incompatible version raises error.""" + import json + + from minidb.errors import VersionMismatchError + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + filepath = f.name + json.dump({'version': '99.0', 'tables': {}}, f) + + try: + with pytest.raises(VersionMismatchError) as exc_info: + MiniDB.load(filepath) + assert '99.0' in str(exc_info.value) + finally: + os.unlink(filepath) + + def test_persistence_invalid_json(self): + """Test loading a file with invalid JSON raises error.""" + from minidb.errors import FileReadError + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + filepath = f.name + f.write('not valid json {{{') + + try: + with pytest.raises(FileReadError) as exc_info: + MiniDB.load(filepath) + assert 'Invalid JSON' in str(exc_info.value) + finally: + os.unlink(filepath) + + def test_persistence_write_to_readonly_path(self): + """Test saving to an unwritable path raises error.""" + from minidb.errors import FileWriteError + + db = MiniDB() + with pytest.raises(FileWriteError): + db.save('/nonexistent/directory/file.json') + def test_json_format_readable(self): """Test that saved JSON is human-readable.""" db = MiniDB() diff --git a/tests/test_queries.py b/tests/test_queries.py index d3448d9..0b726d2 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -1,12 +1,71 @@ """Tests for WHERE, ORDER BY, GROUP BY queries.""" -import sys - import pytest -sys.path.insert(0, '/home/sebtardif/MiniDB') - from minidb import Column, ColumnType, MiniDB +from minidb.errors import SyntaxError_ + + +class TestParserErrors: + """Tests for SQL parsing error paths.""" + + def test_syntax_error_unexpected_token(self): + """Test that malformed SQL raises SyntaxError_.""" + db = MiniDB() + with pytest.raises(SyntaxError_): + db.execute('SELECTX * FROM users') + + def test_syntax_error_unterminated_string(self): + """Test that an unterminated string literal raises SyntaxError_.""" + db = MiniDB() + db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, v STRING)') + with pytest.raises(SyntaxError_, match='Unterminated string'): + db.execute("INSERT INTO t (id, v) VALUES (1, 'hello)") + + def test_syntax_error_unexpected_character(self): + """Test that unexpected characters raise SyntaxError_.""" + db = MiniDB() + with pytest.raises(SyntaxError_): + db.execute('SELECT * FROM t WHERE id @ 1') + + def test_empty_sql(self): + """Test that empty SQL raises SyntaxError_.""" + db = MiniDB() + with pytest.raises(SyntaxError_): + db.execute('') + + +class TestTypeValidation: + """Tests for type validation and NULL constraint errors.""" + + def test_null_value_on_primary_key(self): + """Test inserting NULL into a non-nullable primary key column.""" + from minidb.errors import NullValueError + + db = MiniDB() + db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, v STRING)') + with pytest.raises(NullValueError): + db.execute("INSERT INTO t (id, v) VALUES (NULL, 'hello')") + + def test_insert_with_missing_nullable_column(self): + """Test that missing nullable columns default to NULL.""" + db = MiniDB() + db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY, v STRING)') + db.execute('INSERT INTO t (id) VALUES (1)') + results = db.query('SELECT * FROM t WHERE id = 1') + assert len(results) == 1 + assert results[0]['v'] is None + + def test_duplicate_primary_key_error_message(self): + """Test that DuplicateKeyError includes the key value.""" + from minidb.errors import DuplicateKeyError + + db = MiniDB() + db.execute('CREATE TABLE t (id INTEGER PRIMARY KEY)') + db.execute('INSERT INTO t (id) VALUES (42)') + with pytest.raises(DuplicateKeyError) as exc_info: + db.execute('INSERT INTO t (id) VALUES (42)') + assert '42' in str(exc_info.value) class TestWhereClause: