Skip to content

Commit 0e5580c

Browse files
authored
Add coverage reporting and expand tests (#137)
1 parent 7129482 commit 0e5580c

12 files changed

Lines changed: 881 additions & 21 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444
run: uv run ruff format --check . --exclude docs/_extensions --exclude sidemantic-duckdb/extension-ci-tools --exclude sidemantic-duckdb/scripts --exclude sidemantic-duckdb/duckdb --exclude sidemantic/adapters/malloy_grammar --exclude sidemantic/adapters/holistics_grammar
4545

4646
- name: Run tests
47-
run: uv run pytest -v --cov=sidemantic --cov-report=term-missing
47+
run: uv run pytest -v
4848

4949
update-schema:
5050
name: Update JSON Schema

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,5 @@ Sidemantic is an ambitious but young semantic layer project. You could encounter
353353
```bash
354354
uv run pytest -v
355355
```
356+
357+
This prints line coverage for `sidemantic` with missing lines in the terminal.

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,10 @@ pythonpath = ["."]
170170
markers = [
171171
"integration: marks tests as integration tests requiring external services (deselect with '-m \"not integration\"')",
172172
]
173-
addopts = "-m 'not integration'" # Skip integration tests by default
173+
addopts = "-m 'not integration' --cov=sidemantic --cov-report=term-missing" # Skip integration tests by default and show coverage
174+
175+
[tool.coverage.run]
176+
source = ["sidemantic"]
174177

175178
[tool.uv]
176179
prerelease = "if-necessary"
@@ -191,6 +194,7 @@ dev = [
191194
"pyarrow>=14.0.0",
192195
"pygls>=2.0.0",
193196
"pytest>=8.4.2",
197+
"pytest-cov>=5.0.0",
194198
"ruff>=0.14.0",
195199
"uvicorn>=0.34.0",
196200
]

sidemantic/core/semantic_layer.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ def __exit__(self, exc_type, exc_val, exc_tb):
142142
if hasattr(self.adapter, "close"):
143143
self.adapter.close()
144144

145+
@property
146+
def adapter(self):
147+
"""Database adapter accessor with legacy _adapter compatibility."""
148+
return self._adapter
149+
150+
@adapter.setter
151+
def adapter(self, value):
152+
self._adapter = value
153+
145154
@property
146155
def conn(self):
147156
"""Get raw database connection for backward compatibility."""

sidemantic/server/server.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
from sidemantic.server.connection import SemanticLayerConnection
88

99

10+
def _sql_string_literal(value: str) -> str:
11+
return "'" + value.replace("'", "''") + "'"
12+
13+
1014
def map_type(duckdb_type: str) -> str:
1115
"""Map DuckDB types to PostgreSQL types."""
1216
type_lower = duckdb_type.lower()
@@ -72,11 +76,10 @@ def __init__(self, connection_id, executor):
7276

7377
for schema_name, table_name in tbls:
7478
server._server.register_schema("sidemantic", schema_name)
75-
# Use parameterized query to handle names with special characters (e.g., quotes)
76-
cols_info = layer.adapter.raw_connection.execute(
79+
cols_info = layer.adapter.execute(
7780
"SELECT column_name, data_type, is_nullable FROM information_schema.columns "
78-
"WHERE table_schema = ? AND table_name = ?",
79-
[schema_name, table_name],
81+
f"WHERE table_schema = {_sql_string_literal(schema_name)} "
82+
f"AND table_name = {_sql_string_literal(table_name)}"
8083
).fetchall()
8184
columns = []
8285
for col_name, data_type, is_nullable in cols_info:
@@ -115,7 +118,7 @@ def __init__(self, connection_id, executor):
115118
# Also register the magic 'metrics' table if there are graph-level metrics
116119
if layer.graph.metrics:
117120
metric_columns = []
118-
for metric in layer.graph.metrics:
121+
for metric in layer.graph.metrics.values():
119122
metric_columns.append({metric.name: {"type": "numeric", "nullable": True}})
120123

121124
# Add all dimension columns from all models

tests/db/test_adbc_adapter.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
The tests will use whichever method has SQLite available.
88
"""
99

10+
from types import SimpleNamespace
11+
1012
import pytest
1113

1214
# Check if adbc_driver_manager is available
@@ -417,3 +419,259 @@ def test_adbc_url_path_based_uri():
417419
adapter2 = ADBCAdapter.from_url(f"adbc://{driver_for_url}/:memory:")
418420
assert adapter2.dialect == "sqlite"
419421
adapter2.close()
422+
423+
424+
class _FakeCursor:
425+
def __init__(self, rows=None, description=None, arrow_table=None, close_error=False):
426+
self.rows = list(rows or [])
427+
self.description = description or [("value",)]
428+
self.arrow_table = arrow_table
429+
self.close_error = close_error
430+
self.closed = False
431+
432+
def fetchone(self):
433+
return self.rows[0] if self.rows else None
434+
435+
def fetchall(self):
436+
return list(self.rows)
437+
438+
def close(self):
439+
self.closed = True
440+
if self.close_error:
441+
raise RuntimeError("close failed")
442+
443+
def fetch_arrow_table(self):
444+
return self.arrow_table
445+
446+
447+
def test_adbc_result_fetch_helpers_close_cursor():
448+
import pyarrow as pa
449+
450+
from sidemantic.db.adbc import ADBCResult
451+
452+
cursor = _FakeCursor(rows=[(1,)], description=[("x",)], arrow_table=pa.table({"x": [1]}))
453+
result = ADBCResult(cursor)
454+
assert result.description == [("x",)]
455+
assert result.fetchone() == (1,)
456+
assert cursor.closed is True
457+
458+
cursor2 = _FakeCursor(rows=[(1,), (2,)])
459+
result2 = ADBCResult(cursor2)
460+
assert result2.fetchall() == [(1,), (2,)]
461+
assert cursor2.closed is True
462+
463+
cursor3 = _FakeCursor(arrow_table=pa.table({"x": [1, 2]}), close_error=True)
464+
result3 = ADBCResult(cursor3)
465+
batch_reader = result3.fetch_record_batch()
466+
assert batch_reader.read_all().to_pylist() == [{"x": 1}, {"x": 2}]
467+
assert cursor3.closed is True
468+
469+
470+
def test_adbc_adapter_get_tables_uses_native_metadata():
471+
from sidemantic.db.adbc import ADBCAdapter
472+
473+
adapter = ADBCAdapter.__new__(ADBCAdapter)
474+
adapter.conn = SimpleNamespace(
475+
adbc_get_objects=lambda: SimpleNamespace(
476+
read_all=lambda: SimpleNamespace(
477+
to_pydict=lambda: {
478+
"catalog_db_schemas": [
479+
[
480+
{
481+
"db_schema_name": "analytics",
482+
"db_schema_tables": [{"table_name": "orders"}, {"table_name": "customers"}],
483+
}
484+
]
485+
]
486+
}
487+
)
488+
)
489+
)
490+
491+
tables = adapter.get_tables()
492+
493+
assert tables == [
494+
{"table_name": "orders", "schema": "analytics"},
495+
{"table_name": "customers", "schema": "analytics"},
496+
]
497+
498+
499+
def test_adbc_adapter_get_tables_falls_back_to_information_schema(monkeypatch):
500+
from sidemantic.db.adbc import ADBCAdapter
501+
502+
adapter = ADBCAdapter.__new__(ADBCAdapter)
503+
adapter.conn = SimpleNamespace(adbc_get_objects=lambda: (_ for _ in ()).throw(RuntimeError("no metadata")))
504+
captured = {}
505+
506+
class FakeResult:
507+
def fetchall(self):
508+
return [("orders", "analytics"), ("customers", "public")]
509+
510+
def fake_execute(sql):
511+
captured["sql"] = sql
512+
return FakeResult()
513+
514+
adapter.execute = fake_execute
515+
516+
tables = adapter.get_tables()
517+
518+
assert "information_schema.tables" in captured["sql"]
519+
assert tables == [
520+
{"table_name": "orders", "schema": "analytics"},
521+
{"table_name": "customers", "schema": "public"},
522+
]
523+
524+
525+
def test_adbc_adapter_get_columns_uses_table_schema():
526+
import pyarrow as pa
527+
528+
from sidemantic.db.adbc import ADBCAdapter
529+
530+
adapter = ADBCAdapter.__new__(ADBCAdapter)
531+
adapter._driver_name = "sqlite"
532+
adapter.conn = SimpleNamespace(
533+
adbc_get_table_schema=lambda **kwargs: pa.schema([("id", pa.int64()), ("name", pa.string())])
534+
)
535+
536+
columns = adapter.get_columns("orders")
537+
538+
assert columns == [
539+
{"column_name": "id", "data_type": "int64"},
540+
{"column_name": "name", "data_type": "string"},
541+
]
542+
543+
544+
def test_adbc_adapter_get_columns_uses_objects_metadata_fallback():
545+
from sidemantic.db.adbc import ADBCAdapter
546+
547+
adapter = ADBCAdapter.__new__(ADBCAdapter)
548+
adapter._driver_name = "sqlite"
549+
adapter.conn = SimpleNamespace(
550+
adbc_get_table_schema=lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no schema")),
551+
adbc_get_objects=lambda **kwargs: SimpleNamespace(
552+
read_all=lambda: SimpleNamespace(
553+
to_pydict=lambda: {
554+
"catalog_db_schemas": [
555+
[
556+
{
557+
"db_schema_name": "main",
558+
"db_schema_tables": [
559+
{
560+
"table_name": "orders",
561+
"table_columns": [
562+
{"column_name": "id", "xdbc_type_name": "INTEGER"},
563+
{"column_name": "name", "xdbc_type_name": "TEXT"},
564+
],
565+
}
566+
],
567+
}
568+
]
569+
]
570+
}
571+
)
572+
),
573+
)
574+
575+
columns = adapter.get_columns("orders", schema="main")
576+
577+
assert columns == [
578+
{"column_name": "id", "data_type": "INTEGER"},
579+
{"column_name": "name", "data_type": "TEXT"},
580+
]
581+
582+
583+
def test_adbc_adapter_get_columns_falls_back_to_sql_for_snowflake(monkeypatch):
584+
from sidemantic.db.adbc import ADBCAdapter
585+
586+
adapter = ADBCAdapter.__new__(ADBCAdapter)
587+
adapter._driver_name = "snowflake"
588+
adapter.conn = SimpleNamespace(
589+
adbc_get_table_schema=lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no schema")),
590+
adbc_get_objects=lambda **kwargs: (_ for _ in ()).throw(RuntimeError("no objects")),
591+
)
592+
captured = {}
593+
594+
class FakeResult:
595+
def fetchall(self):
596+
return [("ID", "NUMBER"), ("STATUS", "VARCHAR")]
597+
598+
def fake_execute(sql):
599+
captured["sql"] = sql
600+
return FakeResult()
601+
602+
adapter.execute = fake_execute
603+
604+
columns = adapter.get_columns("orders", schema="analytics")
605+
606+
assert "table_name IN ('ORDERS', 'orders')" in captured["sql"]
607+
assert "table_schema IN ('ANALYTICS', 'analytics')" in captured["sql"]
608+
assert columns == [
609+
{"column_name": "ID", "data_type": "NUMBER"},
610+
{"column_name": "STATUS", "data_type": "VARCHAR"},
611+
]
612+
613+
614+
def test_adbc_adapter_dialect_strips_package_prefix():
615+
from sidemantic.db.adbc import ADBCAdapter
616+
617+
adapter = ADBCAdapter.__new__(ADBCAdapter)
618+
adapter._driver_name = "adbc_driver_postgresql"
619+
620+
assert adapter.dialect == "postgres"
621+
622+
623+
def test_adbc_adapter_close_calls_connection():
624+
from sidemantic.db.adbc import ADBCAdapter
625+
626+
closed = {"value": False}
627+
adapter = ADBCAdapter.__new__(ADBCAdapter)
628+
adapter.conn = SimpleNamespace(close=lambda: closed.__setitem__("value", True))
629+
630+
adapter.close()
631+
632+
assert closed["value"] is True
633+
634+
635+
def test_adbc_adapter_from_url_sqlite_defaults_to_memory(monkeypatch):
636+
from sidemantic.db.adbc import ADBCAdapter
637+
638+
captured = {}
639+
original_init = ADBCAdapter.__init__
640+
641+
def fake_init(self, driver, uri=None, **kwargs):
642+
captured["driver"] = driver
643+
captured["uri"] = uri
644+
captured["kwargs"] = kwargs
645+
646+
monkeypatch.setattr(ADBCAdapter, "__init__", fake_init)
647+
try:
648+
adapter = ADBCAdapter.from_url("adbc://sqlite")
649+
finally:
650+
monkeypatch.setattr(ADBCAdapter, "__init__", original_init)
651+
652+
assert isinstance(adapter, ADBCAdapter)
653+
assert captured["driver"] == "sqlite"
654+
assert captured["uri"] == ":memory:"
655+
656+
657+
def test_adbc_adapter_from_url_adbc_query_params_become_db_kwargs(monkeypatch):
658+
from sidemantic.db.adbc import ADBCAdapter
659+
660+
captured = {}
661+
original_init = ADBCAdapter.__init__
662+
663+
def fake_init(self, driver, uri=None, **kwargs):
664+
captured["driver"] = driver
665+
captured["uri"] = uri
666+
captured["kwargs"] = kwargs
667+
668+
monkeypatch.setattr(ADBCAdapter, "__init__", fake_init)
669+
try:
670+
adapter = ADBCAdapter.from_url("adbc://snowflake?account=myacct&warehouse=wh")
671+
finally:
672+
monkeypatch.setattr(ADBCAdapter, "__init__", original_init)
673+
674+
assert isinstance(adapter, ADBCAdapter)
675+
assert captured["driver"] == "snowflake"
676+
assert captured["uri"] is None
677+
assert captured["kwargs"]["db_kwargs"] == {"account": "myacct", "warehouse": "wh"}

0 commit comments

Comments
 (0)