| """Tests for app/core/duckdb_analytics.py (T13 — RMIV5). |
| |
| Per RMIV5 §T13: DuckDB is an in-process analytics engine for queries |
| too small for ClickHouse but still needing columnar speed. These tests |
| verify: |
| - Basic SELECT returns list[dict] |
| - Parameterized queries (? placeholders) |
| - Parquet round-trip (export + query) |
| - DataFrame registration |
| - Context manager cleanup |
| - Postgres attach (mocked) |
| - Error handling on bad SQL |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import tempfile |
|
|
| import pytest |
|
|
| from app.core.duckdb_analytics import DuckDBAnalytics, get_default_analytics |
|
|
|
|
| @pytest.fixture |
| def db(): |
| """Fresh in-memory DuckDB per test.""" |
| d = DuckDBAnalytics() |
| yield d |
| d.close() |
|
|
|
|
| class TestBasicQuery: |
| """Core SELECT queries.""" |
|
|
| def test_simple_select(self, db) -> None: |
| r = db.query("SELECT 1 AS n, 'hello' AS msg") |
| assert r == [{"n": 1, "msg": "hello"}] |
|
|
| def test_empty_result(self, db) -> None: |
| r = db.query("SELECT 1 AS n WHERE 1 = 0") |
| assert r == [] |
|
|
| def test_multiple_rows(self, db) -> None: |
| r = db.query("SELECT i FROM range(0, 5) t(i) ORDER BY i") |
| assert r == [{"i": 0}, {"i": 1}, {"i": 2}, {"i": 3}, {"i": 4}] |
|
|
| def test_null_values(self, db) -> None: |
| r = db.query("SELECT NULL AS nothing, 1 AS one") |
| assert r == [{"nothing": None, "one": 1}] |
|
|
|
|
| class TestParameterizedQuery: |
| """? placeholder binding.""" |
|
|
| def test_string_param(self, db) -> None: |
| r = db.query("SELECT ? AS name", ["alice"]) |
| assert r == [{"name": "alice"}] |
|
|
| def test_int_param(self, db) -> None: |
| r = db.query("SELECT ? + 10 AS answer", [32]) |
| assert r == [{"answer": 42}] |
|
|
| def test_multiple_params(self, db) -> None: |
| r = db.query( |
| "SELECT ? AS a, ? AS b, ? AS c", |
| ["x", 1, True], |
| ) |
| assert r == [{"a": "x", "b": 1, "c": True}] |
|
|
| def test_params_in_where_clause(self, db) -> None: |
| r = db.query( |
| "SELECT i FROM range(0, 10) t(i) WHERE i > ? AND i < ?", |
| [3, 7], |
| ) |
| assert [row["i"] for row in r] == [4, 5, 6] |
|
|
| def test_no_params_works(self, db) -> None: |
| r = db.query("SELECT 1 AS n") |
| assert r == [{"n": 1}] |
|
|
|
|
| class TestParquetRoundTrip: |
| """Export to Parquet + query back.""" |
|
|
| def test_export_and_query(self, db) -> None: |
| with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f: |
| path = f.name |
| try: |
| n = db.export_to_parquet( |
| "SELECT i, i*2 AS doubled, i*i AS squared FROM range(0, 50) t(i)", |
| path, |
| ) |
| assert n == 50 |
|
|
| |
| r = db.query(f"SELECT count(*) AS n FROM '{path}'") |
| assert r == [{"n": 50}] |
|
|
| |
| r = db.query( |
| f"SELECT count(*) AS n, sum(doubled) AS total FROM '{path}'" |
| ) |
| assert r[0]["n"] == 50 |
| assert r[0]["total"] == 2 * sum(range(50)) |
| finally: |
| os.unlink(path) |
|
|
| def test_query_with_explicit_sql(self, db) -> None: |
| with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f: |
| path = f.name |
| try: |
| db.export_to_parquet( |
| "SELECT chain, count(*) AS n FROM (VALUES ('eth'), ('eth'), ('sol')) t(chain) GROUP BY chain", |
| path, |
| ) |
| |
| r = db.query_parquet( |
| path, "SELECT chain, n FROM parquet ORDER BY chain" |
| ) |
| assert r == [{"chain": "eth", "n": 2}, {"chain": "sol", "n": 1}] |
| finally: |
| os.unlink(path) |
|
|
| def test_export_creates_parent_dirs(self, db) -> None: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| nested = os.path.join(tmpdir, "a", "b", "c", "out.parquet") |
| n = db.export_to_parquet("SELECT 1 AS n", nested) |
| assert n == 1 |
| assert os.path.exists(nested) |
|
|
|
|
| class TestDataFrameRegistration: |
| """Register pandas DataFrames as queryable tables.""" |
|
|
| def test_register_and_query(self, db) -> None: |
| import pandas as pd |
|
|
| df = pd.DataFrame({"name": ["alice", "bob"], "val": [10, 20]}) |
| db.register_dataframe("users", df) |
| r = db.query("SELECT name, val FROM users ORDER BY val DESC") |
| assert r == [{"name": "bob", "val": 20}, {"name": "alice", "val": 10}] |
|
|
| def test_register_with_aggregation(self, db) -> None: |
| import pandas as pd |
|
|
| df = pd.DataFrame({"chain": ["eth", "eth", "sol"], "amount": [100, 200, 50]}) |
| db.register_dataframe("txs", df) |
| r = db.query("SELECT chain, sum(amount) AS total FROM txs GROUP BY chain ORDER BY chain") |
| assert r == [{"chain": "eth", "total": 300}, {"chain": "sol", "total": 50}] |
|
|
|
|
| class TestContextManager: |
| """with-statement lifecycle.""" |
|
|
| def test_context_manager(self) -> None: |
| with DuckDBAnalytics() as d: |
| r = d.query("SELECT 1 AS x") |
| assert r == [{"x": 1}] |
| |
| |
|
|
| def test_explicit_close(self) -> None: |
| d = DuckDBAnalytics() |
| d.query("SELECT 1") |
| d.close() |
| |
| with pytest.raises(Exception): |
| d.query("SELECT 1") |
|
|
|
|
| class TestPersistence: |
| """File-backed DB mode.""" |
|
|
| def test_persistent_db(self) -> None: |
| with tempfile.TemporaryDirectory() as tmpdir: |
| path = os.path.join(tmpdir, "test.db") |
|
|
| |
| d1 = DuckDBAnalytics(persist_path=path) |
| d1.query("CREATE TABLE foo (n INTEGER)") |
| d1.query("INSERT INTO foo VALUES (1), (2), (3)") |
| d1.close() |
|
|
| |
| d2 = DuckDBAnalytics(persist_path=path) |
| r = d2.query("SELECT count(*) AS n FROM foo") |
| assert r == [{"n": 3}] |
| d2.close() |
|
|
|
|
| class TestTableInfo: |
| """table_exists + list_tables.""" |
|
|
| def test_table_exists_true(self, db) -> None: |
| db.query("CREATE TABLE foo (n INTEGER)") |
| assert db.table_exists("foo") is True |
|
|
| def test_table_exists_false(self, db) -> None: |
| assert db.table_exists("nonexistent") is False |
|
|
| def test_list_tables(self, db) -> None: |
| db.query("CREATE TABLE a (x INTEGER)") |
| db.query("CREATE TABLE b (y INTEGER)") |
| db.query("CREATE TABLE c (z INTEGER)") |
| tables = db.list_tables() |
| assert set(tables) >= {"a", "b", "c"} |
|
|
|
|
| class TestErrorHandling: |
| """Bad SQL should raise, not silently return empty.""" |
|
|
| def test_bad_sql_raises(self, db) -> None: |
| with pytest.raises(Exception): |
| db.query("SELECT * FROM nonexistent_table") |
|
|
| def test_syntax_error_raises(self, db) -> None: |
| with pytest.raises(Exception): |
| db.query("THIS IS NOT VALID SQL") |
|
|
|
|
| class TestDefaultAnalytics: |
| """Process-wide singleton.""" |
|
|
| def test_get_default_returns_instance(self) -> None: |
| d = get_default_analytics() |
| assert isinstance(d, DuckDBAnalytics) |
|
|
| def test_default_works(self) -> None: |
| d = get_default_analytics() |
| r = d.query("SELECT 42 AS answer") |
| assert r == [{"answer": 42}] |
|
|
|
|
| class TestPostgresAttach: |
| """Postgres attach (tested with non-existent URL to verify graceful failure).""" |
|
|
| def test_attach_missing_pg_url_fails(self, db, monkeypatch) -> None: |
| """If PG_URL points nowhere, ATTACH should raise (not silently swallow).""" |
| monkeypatch.setenv("PG_URL", "postgres://nobody:nope@localhost:1/none") |
| with pytest.raises(Exception): |
| db.query_postgres("SELECT 1") |
|
|