rugmuncher-backend / tests /unit /core /test_duckdb_analytics.py
Crypto Rug Muncher
clean slate: removing all old data, fresh build
6993919
Raw
History Blame Contribute Delete
7.9 kB
"""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
# Query back
r = db.query(f"SELECT count(*) AS n FROM '{path}'")
assert r == [{"n": 50}]
# Aggregation
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,
)
# Use query_parquet with explicit SQL
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}]
# After exit, connection should be closed (subsequent ops fail)
# We just verify the with-statement works cleanly.
def test_explicit_close(self) -> None:
d = DuckDBAnalytics()
d.query("SELECT 1")
d.close()
# Subsequent ops should fail (connection closed)
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")
# First connection: create data
d1 = DuckDBAnalytics(persist_path=path)
d1.query("CREATE TABLE foo (n INTEGER)")
d1.query("INSERT INTO foo VALUES (1), (2), (3)")
d1.close()
# Second connection: verify data persists
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")