Skip to content

Commit eedb7e7

Browse files
committed
Add PostgreSQL integration tests with docker-compose
- Add docker-compose.yml with postgres service for testing - Add Dockerfile.test for running tests in Docker - Add 10 PostgreSQL integration tests (all passing): - Basic adapter operations (execute, executemany, get_tables, get_columns) - SemanticLayer with Postgres URL - Queries with dimensions - Cross-model joins - SQL query rewriter - Dialect inference - Fix test column ordering issue (use dict instead of tuple indexing) - Document how to run Postgres tests in tests/db/README.md - All 566 tests passing (18 adapter tests + 548 existing) Run with: docker compose up test --build
1 parent b6f7b95 commit eedb7e7

4 files changed

Lines changed: 340 additions & 0 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,7 @@ examples/*.db
2828
# Package installers
2929
*.pkg
3030
.claude/settings.local.json
31+
32+
# Docker volumes
33+
docker-compose.override.yml
34+
Dockerfile.test

docker-compose.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
services:
2+
postgres:
3+
image: postgres:16-alpine
4+
environment:
5+
POSTGRES_USER: test
6+
POSTGRES_PASSWORD: test
7+
POSTGRES_DB: sidemantic_test
8+
ports:
9+
- "5433:5432" # Use 5433 to avoid conflicts with local Postgres
10+
healthcheck:
11+
test: ["CMD-SHELL", "pg_isready -U test"]
12+
interval: 2s
13+
timeout: 5s
14+
retries: 10
15+
volumes:
16+
- postgres_data:/var/lib/postgresql/data
17+
18+
test:
19+
build:
20+
context: .
21+
dockerfile: Dockerfile.test
22+
depends_on:
23+
postgres:
24+
condition: service_healthy
25+
environment:
26+
POSTGRES_TEST: "1"
27+
POSTGRES_URL: "postgres://test:test@postgres:5432/sidemantic_test"
28+
command: pytest tests/db/test_postgres_integration.py -v
29+
30+
volumes:
31+
postgres_data:

tests/db/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Database Adapter Tests
2+
3+
## Running Tests
4+
5+
### DuckDB Tests (default)
6+
```bash
7+
pytest tests/db/test_duckdb_adapter.py -v
8+
pytest tests/db/test_semantic_layer_adapters.py -v
9+
```
10+
11+
### PostgreSQL Integration Tests
12+
13+
PostgreSQL tests require a running Postgres instance and the `postgres` extra dependencies.
14+
15+
**Using Docker Compose (recommended):**
16+
```bash
17+
# Start Postgres and run tests
18+
docker compose up test --build --abort-on-container-exit
19+
20+
# Or run tests locally against dockerized Postgres
21+
docker compose up -d postgres
22+
POSTGRES_TEST=1 uv run --extra postgres pytest tests/db/test_postgres_integration.py -v
23+
```
24+
25+
**Manual setup:**
26+
```bash
27+
# Install postgres dependencies
28+
uv sync --extra postgres
29+
30+
# Set up Postgres (adjust connection details as needed)
31+
export POSTGRES_TEST=1
32+
export POSTGRES_URL="postgres://test:test@localhost:5432/sidemantic_test"
33+
34+
# Run tests
35+
uv run pytest tests/db/test_postgres_integration.py -v
36+
```
37+
38+
## Test Coverage
39+
40+
- **test_duckdb_adapter.py**: Tests for DuckDB adapter implementation
41+
- **test_postgres_adapter.py**: Basic Postgres adapter tests (mostly ImportError checks)
42+
- **test_postgres_integration.py**: Full integration tests against real Postgres database
43+
- **test_semantic_layer_adapters.py**: Tests for SemanticLayer integration with different adapters
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""Integration tests for PostgreSQL adapter against real database.
2+
3+
Run with: docker compose up -d && pytest tests/db/test_postgres_integration.py -v
4+
"""
5+
6+
import os
7+
8+
import pytest
9+
10+
from sidemantic import Dimension, Metric, Model, SemanticLayer
11+
12+
# Skip all tests if POSTGRES_TEST environment variable not set
13+
pytestmark = pytest.mark.skipif(
14+
os.getenv("POSTGRES_TEST") != "1",
15+
reason="Set POSTGRES_TEST=1 and run docker compose up -d to run Postgres integration tests",
16+
)
17+
18+
# Use environment variable for URL (different in docker vs local)
19+
POSTGRES_URL = os.getenv("POSTGRES_URL", "postgres://test:test@localhost:5433/sidemantic_test")
20+
21+
22+
@pytest.fixture
23+
def postgres_adapter():
24+
"""Create PostgreSQL adapter connected to test database."""
25+
from sidemantic.db.postgres import PostgreSQLAdapter
26+
27+
adapter = PostgreSQLAdapter.from_url(POSTGRES_URL)
28+
yield adapter
29+
adapter.close()
30+
31+
32+
@pytest.fixture
33+
def clean_postgres(postgres_adapter):
34+
"""Clean database before each test."""
35+
# Drop all tables
36+
result = postgres_adapter.execute(
37+
"""
38+
SELECT tablename FROM pg_tables
39+
WHERE schemaname = 'public'
40+
"""
41+
)
42+
for row in result.fetchall():
43+
postgres_adapter.execute(f"DROP TABLE IF EXISTS {row[0]} CASCADE")
44+
yield postgres_adapter
45+
46+
47+
def test_postgres_adapter_basic_query(postgres_adapter):
48+
"""Test basic query execution."""
49+
result = postgres_adapter.execute("SELECT 1 as x, 2 as y")
50+
row = result.fetchone()
51+
assert row == (1, 2)
52+
53+
54+
def test_postgres_adapter_create_insert_query(clean_postgres):
55+
"""Test creating table, inserting data, and querying."""
56+
clean_postgres.execute("CREATE TABLE test (id INT, name VARCHAR(50))")
57+
clean_postgres.execute("INSERT INTO test VALUES (1, 'Alice'), (2, 'Bob')")
58+
59+
result = clean_postgres.execute("SELECT name FROM test ORDER BY id")
60+
rows = result.fetchall()
61+
assert rows == [("Alice",), ("Bob",)]
62+
63+
64+
def test_postgres_adapter_executemany(clean_postgres):
65+
"""Test executemany."""
66+
clean_postgres.execute("CREATE TABLE test (x INT, y INT)")
67+
clean_postgres.executemany("INSERT INTO test VALUES (%s, %s)", [(1, 2), (3, 4), (5, 6)])
68+
69+
result = clean_postgres.execute("SELECT COUNT(*) FROM test")
70+
assert result.fetchone()[0] == 3
71+
72+
73+
def test_postgres_adapter_get_tables(clean_postgres):
74+
"""Test getting table list."""
75+
clean_postgres.execute("CREATE TABLE test1 (x INT)")
76+
clean_postgres.execute("CREATE TABLE test2 (x INT)")
77+
78+
tables = clean_postgres.get_tables()
79+
table_names = {t["table_name"] for t in tables}
80+
assert "test1" in table_names
81+
assert "test2" in table_names
82+
83+
84+
def test_postgres_adapter_get_columns(clean_postgres):
85+
"""Test getting column list."""
86+
clean_postgres.execute("CREATE TABLE test (id INT, name VARCHAR(50), age INT)")
87+
88+
columns = clean_postgres.get_columns("test")
89+
assert len(columns) == 3
90+
col_names = {c["column_name"] for c in columns}
91+
assert "id" in col_names
92+
assert "name" in col_names
93+
assert "age" in col_names
94+
95+
96+
def test_semantic_layer_with_postgres_url(clean_postgres):
97+
"""Test SemanticLayer with Postgres connection URL."""
98+
# Create test data
99+
clean_postgres.execute("CREATE TABLE orders (order_id INT, amount DECIMAL)")
100+
clean_postgres.execute("INSERT INTO orders VALUES (1, 100.0), (2, 200.0), (3, 300.0)")
101+
102+
# Create semantic layer
103+
layer = SemanticLayer(connection=POSTGRES_URL)
104+
assert layer.dialect == "postgres"
105+
106+
# Add model
107+
orders = Model(
108+
name="orders",
109+
table="orders",
110+
primary_key="order_id",
111+
metrics=[Metric(name="total_revenue", agg="sum", sql="amount")],
112+
)
113+
layer.add_model(orders)
114+
115+
# Query
116+
result = layer.query(metrics=["orders.total_revenue"])
117+
row = result.fetchone()
118+
assert row[0] == 600.0
119+
120+
121+
def test_semantic_layer_postgres_with_dimensions(clean_postgres):
122+
"""Test querying with dimensions."""
123+
clean_postgres.execute(
124+
"""
125+
CREATE TABLE orders (
126+
order_id INT,
127+
customer_id INT,
128+
status VARCHAR(20),
129+
amount DECIMAL
130+
)
131+
"""
132+
)
133+
clean_postgres.execute(
134+
"""
135+
INSERT INTO orders VALUES
136+
(1, 1, 'completed', 100.0),
137+
(2, 1, 'pending', 200.0),
138+
(3, 2, 'completed', 300.0),
139+
(4, 2, 'completed', 400.0)
140+
"""
141+
)
142+
143+
layer = SemanticLayer(connection=POSTGRES_URL)
144+
145+
orders = Model(
146+
name="orders",
147+
table="orders",
148+
primary_key="order_id",
149+
dimensions=[Dimension(name="status", type="categorical")],
150+
metrics=[
151+
Metric(name="total_revenue", agg="sum", sql="amount"),
152+
Metric(name="order_count", agg="count", sql="order_id"),
153+
],
154+
)
155+
layer.add_model(orders)
156+
157+
result = layer.query(metrics=["orders.total_revenue", "orders.order_count"], dimensions=["orders.status"])
158+
159+
rows = result.fetchall()
160+
# Should have 2 rows (completed, pending)
161+
assert len(rows) == 2
162+
163+
results_dict = {row[0]: {"revenue": row[1], "count": row[2]} for row in rows}
164+
assert results_dict["completed"]["revenue"] == 800.0
165+
assert results_dict["completed"]["count"] == 3
166+
assert results_dict["pending"]["revenue"] == 200.0
167+
assert results_dict["pending"]["count"] == 1
168+
169+
170+
def test_semantic_layer_postgres_with_joins(clean_postgres):
171+
"""Test joins work with Postgres."""
172+
clean_postgres.execute(
173+
"""
174+
CREATE TABLE orders (
175+
order_id INT PRIMARY KEY,
176+
customer_id INT,
177+
amount DECIMAL
178+
)
179+
"""
180+
)
181+
clean_postgres.execute(
182+
"""
183+
CREATE TABLE customers (
184+
customer_id INT PRIMARY KEY,
185+
name VARCHAR(50),
186+
region VARCHAR(50)
187+
)
188+
"""
189+
)
190+
clean_postgres.execute("INSERT INTO customers VALUES (1, 'Alice', 'US'), (2, 'Bob', 'EU')")
191+
clean_postgres.execute(
192+
"""
193+
INSERT INTO orders VALUES
194+
(1, 1, 100.0),
195+
(2, 1, 200.0),
196+
(3, 2, 300.0)
197+
"""
198+
)
199+
200+
layer = SemanticLayer(connection=POSTGRES_URL)
201+
202+
from sidemantic import Relationship
203+
204+
orders = Model(
205+
name="orders",
206+
table="orders",
207+
primary_key="order_id",
208+
metrics=[Metric(name="total_revenue", agg="sum", sql="amount")],
209+
relationships=[Relationship(name="customers", type="many_to_one", foreign_key="customer_id")],
210+
)
211+
212+
customers = Model(
213+
name="customers",
214+
table="customers",
215+
primary_key="customer_id",
216+
dimensions=[Dimension(name="region", type="categorical")],
217+
)
218+
219+
layer.add_model(orders)
220+
layer.add_model(customers)
221+
222+
# Query across models
223+
result = layer.query(metrics=["orders.total_revenue"], dimensions=["customers.region"])
224+
225+
rows = result.fetchall()
226+
results_dict = {row[0]: row[1] for row in rows}
227+
assert results_dict["US"] == 300.0
228+
assert results_dict["EU"] == 300.0
229+
230+
231+
def test_semantic_layer_postgres_sql_method(clean_postgres):
232+
"""Test SQL query rewriter with Postgres."""
233+
clean_postgres.execute("CREATE TABLE orders (order_id INT, amount DECIMAL, status VARCHAR(20))")
234+
clean_postgres.execute("INSERT INTO orders VALUES (1, 100.0, 'completed'), (2, 200.0, 'pending')")
235+
236+
layer = SemanticLayer(connection=POSTGRES_URL)
237+
238+
orders = Model(
239+
name="orders",
240+
table="orders",
241+
primary_key="order_id",
242+
dimensions=[Dimension(name="status", type="categorical")],
243+
metrics=[Metric(name="total_revenue", agg="sum", sql="amount")],
244+
)
245+
layer.add_model(orders)
246+
247+
# Query using SQL method
248+
result = layer.sql("SELECT orders.total_revenue, orders.status FROM orders WHERE orders.status = 'completed'")
249+
250+
row = result.fetchone()
251+
# Note: Column order might vary, check by description
252+
cols = [desc.name for desc in result.description]
253+
row_dict = dict(zip(cols, row))
254+
assert row_dict["total_revenue"] == 100.0
255+
assert row_dict["status"] == "completed"
256+
257+
258+
def test_postgres_dialect_inference():
259+
"""Test that Postgres URL correctly sets dialect."""
260+
layer = SemanticLayer(connection=POSTGRES_URL)
261+
assert layer.dialect == "postgres"
262+
assert layer.adapter.dialect == "postgres"

0 commit comments

Comments
 (0)