import sqlite3 import pytest from sqlalchemy import create_engine import sqlmem.engine as eng_mod from sqlmem import CachingEngine, ReadOnlyError, UnsupportedQueryError # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def source_db(tmp_path): """File-based SQLite source with two pre-populated tables.""" db_path = tmp_path / "source.db" conn = sqlite3.connect(db_path) conn.execute("CREATE TABLE products (id TEXT, name TEXT, price TEXT)") conn.executemany( "INSERT INTO products VALUES (?, ?, ?)", [("1", "Widget", "9.99"), ("2", "Gadget", "19.99"), ("3", "Doohickey", "4.99")], ) conn.execute("CREATE TABLE orders (order_id TEXT, product_id TEXT, qty TEXT)") conn.executemany( "INSERT INTO orders VALUES (?, ?, ?)", [("101", "1", "2"), ("102", "2", "1")], ) conn.commit() conn.close() return db_path @pytest.fixture def source_engine(source_db): engine = create_engine(f"sqlite:///{source_db}") yield engine engine.dispose() @pytest.fixture def cache_path(tmp_path): return tmp_path / "cache.db" @pytest.fixture def engine(source_engine, cache_path, monkeypatch): """CachingEngine pointed at a temp cache DB.""" monkeypatch.setattr(eng_mod, "CACHE_DB_PATH", cache_path) monkeypatch.setattr(eng_mod, "BACKUP_INTERVAL_SECONDS", 9999) ce = CachingEngine(source_engine) yield ce ce.close() # --------------------------------------------------------------------------- # Basic SELECT execution (in-memory) # --------------------------------------------------------------------------- def test_select_returns_list_of_dicts(engine): rows = engine.execute("SELECT id, name FROM products") assert isinstance(rows, list) assert all(isinstance(r, dict) for r in rows) def test_select_correct_row_count(engine): assert len(engine.execute("SELECT id, name FROM products")) == 3 def test_select_correct_values(engine): rows = engine.execute("SELECT id, name FROM products") assert {r["name"] for r in rows} == {"Widget", "Gadget", "Doohickey"} def test_select_with_where_clause(engine): rows = engine.execute("SELECT id, price FROM products WHERE id = '1'") assert len(rows) == 1 assert rows[0]["price"] == "9.99" def test_select_with_order_and_limit(engine): rows = engine.execute("SELECT id, name FROM products ORDER BY id LIMIT 2") assert len(rows) == 2 assert rows[0]["id"] == "1" def test_select_different_table(engine): rows = engine.execute("SELECT order_id, qty FROM orders") assert len(rows) == 2 def test_where_on_non_selected_column(engine): """WHERE references a column not in SELECT — parser must extract it for the cache.""" rows = engine.execute("SELECT name FROM products WHERE price = '9.99'") assert len(rows) == 1 assert rows[0]["name"] == "Widget" # --------------------------------------------------------------------------- # In-memory caching behaviour # --------------------------------------------------------------------------- def test_cache_hit_survives_source_deletion(engine, source_db): engine.execute("SELECT id, name FROM products") # Wipe source — cache must still answer conn = sqlite3.connect(source_db) conn.execute("DELETE FROM products") conn.commit() conn.close() rows = engine.execute("SELECT id, name FROM products") assert len(rows) == 3 def test_new_column_triggers_refetch(engine): engine.execute("SELECT id FROM products") rows = engine.execute("SELECT id, name FROM products") assert "Widget" in {r["name"] for r in rows} def test_second_query_same_columns_is_cache_hit(engine): engine.execute("SELECT id, name FROM products") assert engine._cache.is_table_cached("products") is True rows = engine.execute("SELECT id, name FROM products") assert len(rows) == 3 # --------------------------------------------------------------------------- # SQL file creation — backup to disk # --------------------------------------------------------------------------- def test_close_creates_sql_file(engine, cache_path): engine.execute("SELECT id, name FROM products") engine.close() assert cache_path.exists() def test_sql_file_is_valid_sqlite(engine, cache_path): engine.execute("SELECT id, name FROM products") engine.close() conn = sqlite3.connect(cache_path) tables = {t[0] for t in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} conn.close() assert "_sqlmem_tables" in tables assert "products" in tables def test_sql_file_contains_cached_rows(engine, cache_path): engine.execute("SELECT id, name FROM products") engine.close() conn = sqlite3.connect(cache_path) rows = conn.execute("SELECT id, name FROM products").fetchall() conn.close() assert len(rows) == 3 def test_sql_file_meta_table_present(engine, cache_path): engine.execute("SELECT id FROM products") engine.close() conn = sqlite3.connect(cache_path) row = conn.execute( "SELECT value FROM _sqlmem_meta WHERE key = 'schema_version'" ).fetchone() conn.close() assert row is not None assert int(row[0]) >= 1 def test_reload_from_disk_file(source_engine, cache_path, monkeypatch): """New CachingEngine picks up table cached by a previous instance.""" monkeypatch.setattr(eng_mod, "CACHE_DB_PATH", cache_path) monkeypatch.setattr(eng_mod, "BACKUP_INTERVAL_SECONDS", 9999) ce1 = CachingEngine(source_engine) ce1.execute("SELECT id, name FROM products") ce1.close() ce2 = CachingEngine(source_engine) assert ce2._cache.is_table_cached("products") is True ce2.close() def test_reload_data_intact_after_restart(source_engine, cache_path, monkeypatch): monkeypatch.setattr(eng_mod, "CACHE_DB_PATH", cache_path) monkeypatch.setattr(eng_mod, "BACKUP_INTERVAL_SECONDS", 9999) ce1 = CachingEngine(source_engine) ce1.execute("SELECT id, name FROM products") ce1.close() ce2 = CachingEngine(source_engine) rows = ce2.execute("SELECT id, name FROM products") ce2.close() assert {r["name"] for r in rows} == {"Widget", "Gadget", "Doohickey"} # --------------------------------------------------------------------------- # Error handling # --------------------------------------------------------------------------- def test_insert_raises_readonly(engine): with pytest.raises(ReadOnlyError): engine.execute("INSERT INTO products VALUES ('4', 'New', '1.00')") def test_update_raises_readonly(engine): with pytest.raises(ReadOnlyError): engine.execute("UPDATE products SET price = '0' WHERE id = '1'") def test_delete_raises_readonly(engine): with pytest.raises(ReadOnlyError): engine.execute("DELETE FROM products WHERE id = '1'") def test_ambiguous_unqualified_join_column_raises(engine): with pytest.raises(UnsupportedQueryError): engine.execute( "SELECT name FROM products p JOIN orders o ON p.id = o.product_id" ) # --------------------------------------------------------------------------- # R1 — parametrized queries # --------------------------------------------------------------------------- def test_positional_param(engine): rows = engine.execute("SELECT id, name FROM products WHERE id = ?", ("1",)) assert rows == [{"id": "1", "name": "Widget"}] def test_named_param(engine): rows = engine.execute("SELECT name FROM products WHERE id = :id", {"id": "2"}) assert rows == [{"name": "Gadget"}] # --------------------------------------------------------------------------- # R2 — JOIN support # --------------------------------------------------------------------------- def test_join_two_tables(engine): rows = engine.execute( "SELECT p.name, o.qty FROM products p " "JOIN orders o ON p.id = o.product_id WHERE p.id = ?", ("1",), ) assert rows == [{"name": "Widget", "qty": "2"}] def test_join_caches_both_tables(engine): engine.execute( "SELECT p.name, o.qty FROM products p JOIN orders o ON p.id = o.product_id" ) assert engine._cache.is_table_cached("products") is True assert engine._cache.is_table_cached("orders") is True # --------------------------------------------------------------------------- # R3 — SELECT * # --------------------------------------------------------------------------- def test_select_star_returns_all_columns(engine): rows = engine.execute("SELECT * FROM products WHERE id = '1'") assert rows == [{"id": "1", "name": "Widget", "price": "9.99"}] def test_select_star_marks_table_full(engine): engine.execute("SELECT * FROM products") assert engine._cache.is_table_full("products") is True # --------------------------------------------------------------------------- # Cache invalidation # --------------------------------------------------------------------------- def test_invalidate_marks_table_absent(engine): engine.execute("SELECT id, name FROM products") engine.invalidate("products") assert engine._cache.is_table_cached("products") is False def test_invalidate_then_refetch_works(engine): engine.execute("SELECT id, name FROM products") engine.invalidate("products") rows = engine.execute("SELECT id, name FROM products") assert len(rows) == 3 def test_invalidate_unknown_table_is_noop(engine): engine.invalidate("nonexistent_table") # must not raise