Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,86 @@ jobs:
CLICKHOUSE_PORT: "8123"
CLICKHOUSE_PASSWORD: "clickhouse"
run: uv run pytest -m integration tests/db/test_clickhouse_integration.py -v

adbc-integration:
name: ADBC integration (${{ matrix.db }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
db: [postgres, bigquery, snowflake, clickhouse]

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: sidemantic_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
clickhouse:
image: clickhouse/clickhouse-server:latest
env:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: default
CLICKHOUSE_PASSWORD: clickhouse
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
ports:
- 8123:8123
options: >-
--health-cmd "wget --spider -q localhost:8123/ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- uses: actions/checkout@v4

- name: Start BigQuery emulator
if: matrix.db == 'bigquery'
run: |
docker run -d --name bigquery-emulator \
-p 9050:9050 \
ghcr.io/goccy/bigquery-emulator:latest \
--project=test-project --dataset=test_dataset
sleep 5

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync --extra dev --extra adbc

- name: Install ADBC driver (best effort)
run: |
DB="${{ matrix.db }}"
PKG_DB="$DB"
if [ "$DB" = "postgres" ]; then
PKG_DB="postgresql"
fi
uv pip install "adbc_driver_${PKG_DB}" || uv pip install "adbc-driver-${PKG_DB}" || true

- name: Run ADBC smoke tests
env:
ADBC_TEST: "1"
ADBC_DB: ${{ matrix.db }}
POSTGRES_URL: "postgres://test:test@localhost:5432/sidemantic_test"
BIGQUERY_EMULATOR_HOST: "localhost:9050"
BIGQUERY_PROJECT: "test-project"
BIGQUERY_DATASET: "test_dataset"
CLICKHOUSE_HOST: "localhost"
CLICKHOUSE_PORT: "8123"
CLICKHOUSE_PASSWORD: "clickhouse"
SNOWFLAKE_TEST: "1"
run: uv run pytest -m integration tests/db/test_adbc_ci_smoke.py -v
2 changes: 2 additions & 0 deletions sidemantic/db/adbc.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def fetch_record_batch(self) -> Any:
# Map driver names to SQLGlot dialect names
DRIVER_DIALECT_MAP = {
"bigquery": "bigquery",
"clickhouse": "clickhouse",
"duckdb": "duckdb",
"flightsql": None, # Generic SQL, no specific dialect
"mssql": "tsql",
Expand Down Expand Up @@ -373,6 +374,7 @@ def from_url(cls, url: str) -> "ADBCAdapter":
"sqlite": "sqlite",
"snowflake": "snowflake",
"bigquery": "bigquery",
"clickhouse": "clickhouse",
"mssql": "mssql",
"trino": "trino",
"redshift": "redshift",
Expand Down
28 changes: 24 additions & 4 deletions tests/db/test_adbc_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,8 @@ def _check_sqlite_driver() -> tuple[bool, str]:

HAS_SQLITE_DRIVER, SQLITE_DRIVER_NAME = _check_sqlite_driver()

pytestmark = pytest.mark.skipif(
not (HAS_ADBC and HAS_SQLITE_DRIVER),
reason="adbc_driver_manager and sqlite driver (pip or dbc) required for ADBC tests",
)
SQLITE_TESTS_AVAILABLE = HAS_ADBC and HAS_SQLITE_DRIVER
SQLITE_TESTS_SKIP_REASON = "adbc_driver_manager and sqlite driver (pip or dbc) required for ADBC connection tests"


@pytest.fixture
Expand All @@ -62,6 +60,9 @@ def sqlite_adapter():

Uses :memory: for test isolation - each test gets a fresh database.
"""
if not SQLITE_TESTS_AVAILABLE:
pytest.skip(SQLITE_TESTS_SKIP_REASON)

from sidemantic.db.adbc import ADBCAdapter

adapter = ADBCAdapter(driver=SQLITE_DRIVER_NAME, uri=":memory:")
Expand All @@ -74,6 +75,9 @@ def sqlite_adapter():

def test_adbc_adapter_init():
"""Test ADBC adapter initialization."""
if not SQLITE_TESTS_AVAILABLE:
pytest.skip(SQLITE_TESTS_SKIP_REASON)

from sidemantic.db.adbc import ADBCAdapter

adapter = ADBCAdapter(driver=SQLITE_DRIVER_NAME, uri=":memory:")
Expand Down Expand Up @@ -139,6 +143,7 @@ def test_adbc_adapter_dialect_mapping():
assert DRIVER_DIALECT_MAP["mysql"] == "mysql"
assert DRIVER_DIALECT_MAP["snowflake"] == "snowflake"
assert DRIVER_DIALECT_MAP["bigquery"] == "bigquery"
assert DRIVER_DIALECT_MAP["clickhouse"] == "clickhouse"
assert DRIVER_DIALECT_MAP["sqlite"] == "sqlite"
assert DRIVER_DIALECT_MAP["duckdb"] == "duckdb"

Expand Down Expand Up @@ -206,6 +211,9 @@ def test_adbc_adapter_valid_table_names_accepted(sqlite_adapter, table_name):

def test_adbc_adapter_from_url():
"""Test creating adapter from URL."""
if not SQLITE_TESTS_AVAILABLE:
pytest.skip(SQLITE_TESTS_SKIP_REASON)

from sidemantic.db.adbc import ADBCAdapter

# SQLite supports URL-based connection
Expand Down Expand Up @@ -254,6 +262,9 @@ def test_adbc_with_sqlite_driver():
- DBC CLI: dbc install sqlite
- Python package: pip install adbc_driver_sqlite
"""
if not SQLITE_TESTS_AVAILABLE:
pytest.skip(SQLITE_TESTS_SKIP_REASON)

from sidemantic.db.adbc import ADBCAdapter

# Create adapter using the detected driver name with :memory: for isolation
Expand All @@ -277,6 +288,9 @@ def test_adbc_with_sqlite_driver():

def test_adbc_url_scheme_with_driver():
"""Test adbc:// URL scheme with installed driver."""
if not SQLITE_TESTS_AVAILABLE:
pytest.skip(SQLITE_TESTS_SKIP_REASON)

from sidemantic.db.adbc import ADBCAdapter

# Test adbc:// URL format - use the short name if DBC driver, else package name
Expand All @@ -292,6 +306,9 @@ def test_adbc_url_scheme_with_driver():

def test_semantic_layer_with_adbc_connection():
"""Test SemanticLayer integration with ADBC connection."""
if not SQLITE_TESTS_AVAILABLE:
pytest.skip(SQLITE_TESTS_SKIP_REASON)

from sidemantic import Dimension, Metric, Model, SemanticLayer

# Create layer with adbc:// connection URL
Expand Down Expand Up @@ -383,6 +400,9 @@ def test_adbc_url_parsing():

def test_adbc_url_path_based_uri():
"""Test adbc:// URL with URI as path (adbc://driver/uri format)."""
if not SQLITE_TESTS_AVAILABLE:
pytest.skip(SQLITE_TESTS_SKIP_REASON)

from sidemantic.db.adbc import ADBCAdapter

# Test path-based URI with SQLite
Expand Down
155 changes: 155 additions & 0 deletions tests/db/test_adbc_ci_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Best-effort ADBC integration smoke tests for CI.

These tests are intended to run in CI against whatever ADBC drivers are available.
If an ADBC driver (or a usable endpoint) is not available for a given DB, the
tests will skip rather than fail.
"""

from __future__ import annotations

import importlib
import os

import pytest

from sidemantic import Metric, Model, SemanticLayer

pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(os.getenv("ADBC_TEST") != "1", reason="Set ADBC_TEST=1 to run ADBC CI smoke tests"),
]


def _adbc_db() -> str:
db = os.getenv("ADBC_DB")
if not db:
pytest.skip("ADBC_DB is not set")
return db.lower()


def _expected_dialect(db: str) -> str:
expected = {
"postgres": "postgres",
"bigquery": "bigquery",
"snowflake": "snowflake",
"clickhouse": "clickhouse",
}.get(db)
if not expected:
pytest.skip(f"Unsupported ADBC_DB={db!r}")
return expected


def _target_uri(db: str) -> str:
if db == "postgres":
uri = os.getenv("POSTGRES_URL")
if not uri:
pytest.skip("POSTGRES_URL is not set")
return uri

if db == "bigquery":
emulator_host = os.getenv("BIGQUERY_EMULATOR_HOST")
if emulator_host:
os.environ["BIGQUERY_EMULATOR_HOST"] = emulator_host
project = os.getenv("BIGQUERY_PROJECT", "test-project")
dataset = os.getenv("BIGQUERY_DATASET", "test_dataset")
return f"bigquery://{project}/{dataset}"

if db == "snowflake":
return "snowflake://test:test@test/testdb/public?warehouse=test_warehouse"

if db == "clickhouse":
host = os.getenv("CLICKHOUSE_HOST", "localhost")
port = os.getenv("CLICKHOUSE_PORT", "8123")
password = os.getenv("CLICKHOUSE_PASSWORD", "clickhouse")
return f"clickhouse://default:{password}@{host}:{port}/default"

pytest.skip(f"Unsupported ADBC_DB={db!r}")


def _driver_suffix(db: str) -> str:
if db == "postgres":
return "postgresql"
return db


def _candidate_adbc_drivers(db: str) -> list[str]:
suffix = _driver_suffix(db)
candidates: list[str] = []

pkg_name = f"adbc_driver_{suffix}"
try:
importlib.import_module(pkg_name)
except Exception:
pass
else:
candidates.append(pkg_name)

candidates.append(suffix)
return candidates


@pytest.fixture(scope="module")
def adbc_layer() -> SemanticLayer:
db = _adbc_db()
uri = _target_uri(db)
candidates = _candidate_adbc_drivers(db)

from sidemantic.db.adbc import ADBCAdapter

last_exc: Exception | None = None
for driver in candidates:
try:
adapter = ADBCAdapter(driver=driver, uri=uri)
except Exception as exc:
last_exc = exc
continue
layer = SemanticLayer(connection=adapter)
try:
probe = layer.adapter.execute("SELECT 1 as x")
row = probe.fetchone()
if row != (1,):
raise RuntimeError(f"Unexpected probe result: {row!r}")
except Exception as exc:
last_exc = exc
try:
adapter.close()
except Exception:
pass
continue

yield layer
try:
adapter.close()
except Exception:
pass
return

details = f"Tried drivers={candidates!r}. URI={uri!r}."
if last_exc is not None:
details += f" Last error={last_exc!r}"
pytest.skip(f"No working ADBC driver for {db!r}. {details}")


def test_adbc_smoke_basic_execute(adbc_layer: SemanticLayer) -> None:
result = adbc_layer.adapter.execute("SELECT 1 as x, 2 as y")
assert result.fetchone() == (1, 2)
Comment on lines +133 to +135

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip when ADBC query fails for unreachable Snowflake

The CI matrix includes a Snowflake run but there’s no Snowflake service configured; _target_uri() hardcodes a dummy Snowflake URL, and the fixture only skips when ADBCAdapter initialization fails. Many ADBC drivers connect lazily, so execute("SELECT 1 as x, 2 as y") can be the first point of failure, which will raise and fail the test instead of skipping. This makes the “best‑effort” smoke job flaky or consistently failing whenever adbc_driver_snowflake installs successfully. Consider probing the connection in the fixture (and pytest.skip on failure) to keep the job best‑effort as intended.

Useful? React with 👍 / 👎.



def test_adbc_smoke_semantic_layer_query_sum(adbc_layer: SemanticLayer) -> None:
orders = Model(
name="orders",
table="(SELECT 1 as id, 10 as amount UNION ALL SELECT 2, 20)",
primary_key="id",
metrics=[Metric(name="total_amount", agg="sum", sql="amount")],
)
adbc_layer.add_model(orders)

result = adbc_layer.query(metrics=["orders.total_amount"])
row = result.fetchone()
assert row is not None
assert float(row[0]) == 30.0


def test_adbc_smoke_dialect(adbc_layer: SemanticLayer) -> None:
expected = _expected_dialect(_adbc_db())
assert adbc_layer.dialect == expected