diff --git a/.github/workflows/docs-r-pkgdown.yml b/.github/workflows/docs-r-pkgdown.yml
index 68313a93..baeec2ad 100644
--- a/.github/workflows/docs-r-pkgdown.yml
+++ b/.github/workflows/docs-r-pkgdown.yml
@@ -48,7 +48,7 @@ jobs:
- uses: r-lib/actions/setup-r-dependencies@v2
with:
- extra-packages: any::pkgdown, local::.
+ extra-packages: any::pkgdown, any::brand.yml, local::.
needs: website
working-directory: pkg-r
diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md
index efda1d24..ae25050b 100644
--- a/pkg-py/CHANGELOG.md
+++ b/pkg-py/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### New features
+
+* Added support for Snowflake Semantic Views. When connected to Snowflake (via SQLAlchemy or Ibis), querychat automatically discovers available Semantic Views and includes their definitions in the system prompt. This helps the LLM generate correct queries using the `SEMANTIC_VIEW()` table function with certified business metrics and dimensions. (#200)
+
## [0.5.1] - 2026-01-23
### New features
diff --git a/pkg-py/src/querychat/_datasource.py b/pkg-py/src/querychat/_datasource.py
index e5bdcc93..5cac5f08 100644
--- a/pkg-py/src/querychat/_datasource.py
+++ b/pkg-py/src/querychat/_datasource.py
@@ -11,6 +11,10 @@
from sqlalchemy.sql import sqltypes
from ._df_compat import read_sql
+from ._snowflake import (
+ discover_semantic_views,
+ format_semantic_views,
+)
from ._utils import as_narwhals, check_query
if TYPE_CHECKING:
@@ -179,6 +183,10 @@ def cleanup(self) -> None:
"""
+ def get_semantic_views_description(self) -> str:
+ """Get information about semantic views (if any) for the system prompt."""
+ return ""
+
class DataFrameSource(DataSource[IntoDataFrameT]):
"""A DataSource implementation that wraps a DataFrame using DuckDB."""
@@ -489,6 +497,13 @@ def get_schema(self, *, categorical_threshold: int) -> str:
self._add_column_stats(columns, categorical_threshold)
return format_schema(self.table_name, columns)
+ def get_semantic_views_description(self) -> str:
+ """Get information about semantic views (if any) for the system prompt."""
+ if self._engine.dialect.name.lower() != "snowflake":
+ return ""
+ views = discover_semantic_views(self._engine)
+ return format_semantic_views(views)
+
@staticmethod
def _make_column_meta(name: str, sa_type: sqltypes.TypeEngine) -> ColumnMeta:
"""Create ColumnMeta from SQLAlchemy type."""
@@ -895,8 +910,7 @@ def _add_column_stats(
# Find text columns that qualify as categorical
categorical_cols = [
- col
- for col in columns
+ col for col in columns
if col.kind == "text"
and (nunique := stats.get(f"{col.name}__nunique"))
and nunique <= categorical_threshold
@@ -960,6 +974,13 @@ def get_schema(self, *, categorical_threshold: int) -> str:
self._add_column_stats(columns, self._table, categorical_threshold)
return format_schema(self.table_name, columns)
+ def get_semantic_views_description(self) -> str:
+ """Get information about semantic views (if any) for the system prompt."""
+ if self._backend.name.lower() != "snowflake":
+ return ""
+ views = discover_semantic_views(self._backend)
+ return format_semantic_views(views)
+
@staticmethod
def _make_column_meta(name: str, dtype: IbisDataType) -> ColumnMeta:
"""Create ColumnMeta from an ibis dtype."""
@@ -1018,8 +1039,7 @@ def _add_column_stats(
col.max_val = stats.get(f"{col.name}__max")
categorical_cols = [
- col
- for col in columns
+ col for col in columns
if col.kind == "text"
and (nunique := stats.get(f"{col.name}__nunique"))
and nunique <= categorical_threshold
diff --git a/pkg-py/src/querychat/_snowflake.py b/pkg-py/src/querychat/_snowflake.py
new file mode 100644
index 00000000..381c9cd7
--- /dev/null
+++ b/pkg-py/src/querychat/_snowflake.py
@@ -0,0 +1,126 @@
+"""
+Snowflake-specific utilities for semantic view discovery.
+
+This module provides functions for discovering Snowflake Semantic Views,
+supporting both SQLAlchemy engines and Ibis backends via isinstance() checks.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import sqlalchemy
+
+if TYPE_CHECKING:
+ from ibis.backends.sql import SQLBackend
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SemanticViewInfo:
+ """Metadata for a Snowflake Semantic View."""
+
+ name: str
+ """Fully qualified name (database.schema.view_name)."""
+
+ ddl: str
+ """The DDL definition from GET_DDL()."""
+
+
+def execute_raw_sql(
+ query: str,
+ backend: sqlalchemy.Engine | SQLBackend,
+) -> list[dict[str, Any]]:
+ """Execute raw SQL and return results as list of row dicts."""
+ if isinstance(backend, sqlalchemy.Engine):
+ with backend.connect() as conn:
+ result = conn.execute(sqlalchemy.text(query))
+ keys = list(result.keys())
+ return [dict(zip(keys, row, strict=False)) for row in result.fetchall()]
+ else:
+ with backend.raw_sql(query) as cursor: # type: ignore[union-attr]
+ columns = [desc[0] for desc in cursor.description]
+ return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
+
+
+def discover_semantic_views(
+ backend: sqlalchemy.Engine | SQLBackend,
+) -> list[SemanticViewInfo]:
+ """Discover semantic views in the current schema."""
+ if os.environ.get("QUERYCHAT_DISABLE_SEMANTIC_VIEWS"):
+ return []
+
+ rows = execute_raw_sql("SHOW SEMANTIC VIEWS", backend)
+
+ if not rows:
+ logger.debug("No semantic views found in current schema")
+ return []
+
+ views: list[SemanticViewInfo] = []
+ for row in rows:
+ db = row.get("database_name")
+ schema = row.get("schema_name")
+ name = row.get("name")
+
+ if not name:
+ continue
+
+ fq_name = f"{db}.{schema}.{name}"
+ ddl = get_semantic_view_ddl(backend, fq_name)
+ if ddl:
+ views.append(SemanticViewInfo(name=fq_name, ddl=ddl))
+
+ return views
+
+
+def get_semantic_view_ddl(
+ backend: sqlalchemy.Engine | SQLBackend,
+ fq_name: str,
+) -> str | None:
+ """Get DDL for a semantic view by fully qualified name."""
+ safe_name = fq_name.replace("'", "''")
+ rows = execute_raw_sql(f"SELECT GET_DDL('SEMANTIC_VIEW', '{safe_name}')", backend)
+ if rows:
+ return str(next(iter(rows[0].values())))
+ return None
+
+
+def format_semantic_view_ddls(semantic_views: list[SemanticViewInfo]) -> str:
+ """Format just the DDL definitions for semantic views."""
+ lines: list[str] = []
+
+ for sv in semantic_views:
+ lines.append(f"### Semantic View: `{sv.name}`")
+ lines.append("")
+ lines.append("```sql")
+ lines.append(sv.ddl)
+ lines.append("```")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def format_semantic_views(semantic_views: list[SemanticViewInfo]) -> str:
+ """Build the complete semantic views section for the prompt."""
+ if not semantic_views:
+ return ""
+
+ from importlib.resources import files
+
+ prompts = files("querychat.prompts.semantic-views")
+ prompt_text = (prompts / "prompt.md").read_text()
+ syntax_text = (prompts / "syntax.md").read_text()
+ ddls_text = format_semantic_view_ddls(semantic_views)
+
+ return f"""{prompt_text}
+
+{syntax_text}
+
+
+{ddls_text}
+
+"""
diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py
index 2b9cdb04..7b4f737a 100644
--- a/pkg-py/src/querychat/_system_prompt.py
+++ b/pkg-py/src/querychat/_system_prompt.py
@@ -65,11 +65,13 @@ def render(self, tools: tuple[TOOL_GROUPS, ...] | None) -> str:
Fully rendered system prompt string
"""
- is_duck_db = self.data_source.get_db_type().lower() == "duckdb"
+ db_type = self.data_source.get_db_type()
+ is_duck_db = db_type.lower() == "duckdb"
context = {
- "db_type": self.data_source.get_db_type(),
+ "db_type": db_type,
"is_duck_db": is_duck_db,
+ "semantic_views": self.data_source.get_semantic_views_description(),
"schema": self.schema,
"data_description": self.data_description,
"extra_instructions": self.extra_instructions,
diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md
index 7c8ea5a1..8c6ff97b 100644
--- a/pkg-py/src/querychat/prompts/prompt.md
+++ b/pkg-py/src/querychat/prompts/prompt.md
@@ -71,6 +71,7 @@ quantile_cont(salary, 0.5)
```
{{/is_duck_db}}
+{{{semantic_views}}}
## Your Capabilities
You can handle these types of requests:
diff --git a/pkg-py/src/querychat/prompts/semantic-views/prompt.md b/pkg-py/src/querychat/prompts/semantic-views/prompt.md
new file mode 100644
index 00000000..32f448ee
--- /dev/null
+++ b/pkg-py/src/querychat/prompts/semantic-views/prompt.md
@@ -0,0 +1,5 @@
+## Semantic Views
+
+**IMPORTANT**: This database has Semantic Views available. Semantic Views provide a curated layer over raw data with pre-defined metrics, dimensions, and relationships. They encode business logic and calculation rules that ensure consistent, accurate results. When a Semantic View covers the data you need, prefer it over raw tables to benefit from these certified definitions (that is, use the `SEMANTIC_VIEW()` table function where appropriate when generating SQL).
+
+**Real-world example**: A legacy ERP database had a revenue column (`X_AMT`) with hidden business rules—only status code 90 transactions count as realized revenue, and a discount factor (`ADJ_FCTR`) must be applied. Querying raw tables for "external customer revenue" returned **$184B**. The same query using the semantic model's certified `NET_REVENUE` metric returned **$84.5B**—the correct answer. The raw query was **2x+ too high** because it ignored discounts and included invalid transaction codes.
diff --git a/pkg-py/src/querychat/prompts/semantic-views/syntax.md b/pkg-py/src/querychat/prompts/semantic-views/syntax.md
new file mode 100644
index 00000000..32988f38
--- /dev/null
+++ b/pkg-py/src/querychat/prompts/semantic-views/syntax.md
@@ -0,0 +1,96 @@
+### SEMANTIC_VIEW() Query Syntax
+
+#### Basic Syntax
+
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ {view_name}
+ METRICS {logical_table}.{metric_name}
+ DIMENSIONS {logical_table}.{dimension_name}
+ [WHERE {dimension} = 'value'] -- Optional: pre-aggregation filter
+)
+[WHERE {column} = 'value'] -- Optional: post-aggregation filter
+```
+
+#### Key Rules
+
+1. **Use `SEMANTIC_VIEW()` function** - Not direct SELECT FROM the view
+2. **No GROUP BY needed** - Semantic layer handles aggregation via DIMENSIONS
+3. **No JOINs needed within model** - Relationships are pre-defined
+4. **No aggregate functions needed** - Metrics are pre-aggregated
+5. **Use DDL-defined names** - Metrics and dimensions must match the DDL exactly
+
+#### WHERE Clause: Inside vs Outside
+
+- **Inside** (pre-aggregation): Filters base data BEFORE metrics are computed
+- **Outside** (post-aggregation): Filters results AFTER metrics are computed
+
+```sql
+-- Pre-aggregation: only include 'EXT' accounts in the calculation
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+ WHERE REF_ENTITIES.ACC_TYPE_CD = 'EXT'
+)
+
+-- Post-aggregation: compute all, then filter results
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+)
+WHERE NET_REVENUE > 1000000
+```
+
+#### Common Patterns
+
+**Single metric (total):**
+```sql
+SELECT * FROM SEMANTIC_VIEW(MODEL_NAME METRICS T_DATA.NET_REVENUE)
+```
+
+**Metric by dimension:**
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+)
+```
+
+**Multiple metrics and dimensions:**
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE, T_DATA.GROSS_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD, T_DATA.LOG_DT
+)
+ORDER BY LOG_DT ASC
+```
+
+**Time series:**
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS T_DATA.LOG_DT
+)
+ORDER BY LOG_DT ASC
+```
+
+**Join results with other data:**
+```sql
+SELECT sv.*, lookup.category_name
+FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+) AS sv
+JOIN category_lookup AS lookup ON sv.ACC_TYPE_CD = lookup.code
+```
+
+#### Troubleshooting
+
+- **"Invalid identifier"**: Verify metric/dimension names match exactly what's in the DDL
+- **Syntax error**: Use SEMANTIC_VIEW() function, GROUP BY isn't needed
diff --git a/pkg-py/tests/test_snowflake_source.py b/pkg-py/tests/test_snowflake_source.py
new file mode 100644
index 00000000..168401ad
--- /dev/null
+++ b/pkg-py/tests/test_snowflake_source.py
@@ -0,0 +1,445 @@
+"""Tests for Snowflake semantic view functionality."""
+
+import logging
+import os
+from unittest.mock import MagicMock, patch
+
+from querychat._snowflake import (
+ SemanticViewInfo,
+ discover_semantic_views,
+ execute_raw_sql,
+ format_semantic_view_ddls,
+ get_semantic_view_ddl,
+)
+
+
+# Decorator to make MagicMock pass isinstance(mock, sqlalchemy.Engine)
+def patch_sqlalchemy_engine(func):
+ """Patch sqlalchemy.Engine so MagicMock instances pass isinstance checks."""
+ return patch("querychat._snowflake.sqlalchemy.Engine", MagicMock)(func)
+
+
+class TestSemanticViewInfo:
+ """Tests for SemanticViewInfo dataclass."""
+
+ def test_creation(self):
+ """Test basic creation of SemanticViewInfo."""
+ info = SemanticViewInfo(name="db.schema.view", ddl="CREATE SEMANTIC VIEW...")
+ assert info.name == "db.schema.view"
+ assert info.ddl == "CREATE SEMANTIC VIEW..."
+
+ def test_equality(self):
+ """Test equality comparison."""
+ info1 = SemanticViewInfo(name="db.schema.view", ddl="DDL")
+ info2 = SemanticViewInfo(name="db.schema.view", ddl="DDL")
+ info3 = SemanticViewInfo(name="db.schema.other", ddl="DDL")
+ assert info1 == info2
+ assert info1 != info3
+
+
+class TestFormatSemanticViewDdls:
+ """Tests for semantic view DDL formatting."""
+
+ def test_format_single_view(self):
+ """Test that format produces expected markdown structure for single view."""
+ views = [SemanticViewInfo(name="db.schema.view1", ddl="CREATE SEMANTIC VIEW v1")]
+ section = format_semantic_view_ddls(views)
+
+ assert "db.schema.view1" in section
+ assert "CREATE SEMANTIC VIEW v1" in section
+ assert "```sql" in section
+
+ def test_format_multiple_views(self):
+ """Test formatting with multiple views."""
+ views = [
+ SemanticViewInfo(name="db.schema.view1", ddl="CREATE SEMANTIC VIEW v1"),
+ SemanticViewInfo(name="db.schema.view2", ddl="CREATE SEMANTIC VIEW v2"),
+ ]
+ section = format_semantic_view_ddls(views)
+
+ assert "db.schema.view1" in section
+ assert "db.schema.view2" in section
+ assert "CREATE SEMANTIC VIEW v1" in section
+ assert "CREATE SEMANTIC VIEW v2" in section
+
+
+class TestSQLEscaping:
+ """Tests for SQL injection prevention in get_semantic_view_ddl."""
+
+ @patch_sqlalchemy_engine
+ def test_single_quote_escaped(self):
+ """Verify that names with single quotes are properly escaped."""
+ mock_engine = MagicMock()
+ mock_conn = MagicMock()
+ mock_result = MagicMock()
+ mock_result.keys.return_value = ["col"]
+ mock_result.fetchall.return_value = [("DDL result",)]
+
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_conn.execute.return_value = mock_result
+
+ get_semantic_view_ddl(mock_engine, "db.schema.test'view")
+
+ # Verify the executed query has escaped quotes
+ call_args = mock_conn.execute.call_args
+ query_str = str(call_args[0][0])
+ assert "test''view" in query_str
+
+ @patch_sqlalchemy_engine
+ def test_normal_name_unchanged(self):
+ """Verify that normal names without special chars work correctly."""
+ mock_engine = MagicMock()
+ mock_conn = MagicMock()
+ mock_result = MagicMock()
+ mock_result.keys.return_value = ["col"]
+ mock_result.fetchall.return_value = [("DDL result",)]
+
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_conn.execute.return_value = mock_result
+
+ get_semantic_view_ddl(mock_engine, "db.schema.normal_view")
+
+ call_args = mock_conn.execute.call_args
+ query_str = str(call_args[0][0])
+ assert "db.schema.normal_view" in query_str
+
+
+class TestExecuteRawSQL:
+ """Tests for execute_raw_sql function."""
+
+ @patch_sqlalchemy_engine
+ def test_sqlalchemy_backend(self):
+ """Test execute_raw_sql with SQLAlchemy backend."""
+ mock_engine = MagicMock()
+ mock_conn = MagicMock()
+ mock_result = MagicMock()
+ mock_result.keys.return_value = ["col1", "col2"]
+ mock_result.fetchall.return_value = [("a", "b"), ("c", "d")]
+
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_conn.execute.return_value = mock_result
+
+ result = execute_raw_sql("SELECT 1", mock_engine)
+
+ assert result == [{"col1": "a", "col2": "b"}, {"col1": "c", "col2": "d"}]
+
+ def test_ibis_backend(self):
+ """Test execute_raw_sql with Ibis backend."""
+ mock_backend = MagicMock()
+ mock_cursor = MagicMock()
+ mock_cursor.description = [("col1",), ("col2",)]
+ mock_cursor.fetchall.return_value = [("a", "b"), ("c", "d")]
+
+ # raw_sql returns a context manager
+ mock_backend.raw_sql.return_value.__enter__ = MagicMock(return_value=mock_cursor)
+ mock_backend.raw_sql.return_value.__exit__ = MagicMock(return_value=False)
+
+ result = execute_raw_sql("SELECT 1", mock_backend)
+
+ assert result == [{"col1": "a", "col2": "b"}, {"col1": "c", "col2": "d"}]
+ mock_backend.raw_sql.assert_called_once_with("SELECT 1")
+
+
+class TestDiscoverSemanticViews:
+ """Tests for the discover_semantic_views function."""
+
+ @patch_sqlalchemy_engine
+ def test_discover_returns_views(self):
+ """Test successful discovery of semantic views."""
+ mock_engine = MagicMock()
+ mock_conn = MagicMock()
+
+ # Set up sequence of results for execute_raw_sql calls
+ results = [
+ # First call: SHOW SEMANTIC VIEWS
+ [
+ {"database_name": "DB", "schema_name": "SCH", "name": "VIEW1"},
+ {"database_name": "DB", "schema_name": "SCH", "name": "VIEW2"},
+ ],
+ # Second call: GET_DDL for VIEW1
+ [{"col": "DDL1"}],
+ # Third call: GET_DDL for VIEW2
+ [{"col": "DDL2"}],
+ ]
+ call_count = [0]
+
+ def mock_execute(_query):
+ result = MagicMock()
+ current_result = results[call_count[0]]
+ call_count[0] += 1
+
+ if isinstance(current_result, list) and current_result:
+ keys = list(current_result[0].keys())
+ rows = [tuple(r.values()) for r in current_result]
+ else:
+ keys = []
+ rows = []
+
+ result.keys.return_value = keys
+ result.fetchall.return_value = rows
+ return result
+
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_conn.execute.side_effect = mock_execute
+
+ views = discover_semantic_views(mock_engine)
+
+ assert len(views) == 2
+ assert views[0].name == "DB.SCH.VIEW1"
+ assert views[0].ddl == "DDL1"
+ assert views[1].name == "DB.SCH.VIEW2"
+ assert views[1].ddl == "DDL2"
+
+ @patch_sqlalchemy_engine
+ def test_discover_no_views(self, caplog):
+ """Test discovery when no views exist."""
+ mock_engine = MagicMock()
+ mock_conn = MagicMock()
+ mock_result = MagicMock()
+ mock_result.keys.return_value = []
+ mock_result.fetchall.return_value = []
+
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_conn.execute.return_value = mock_result
+
+ with caplog.at_level(logging.DEBUG, logger="querychat._snowflake"):
+ views = discover_semantic_views(mock_engine)
+
+ assert views == []
+ assert "No semantic views found" in caplog.text
+
+ def test_discover_disabled_via_env_var(self):
+ """Test that QUERYCHAT_DISABLE_SEMANTIC_VIEWS disables discovery."""
+ mock_engine = MagicMock()
+
+ with patch.dict(os.environ, {"QUERYCHAT_DISABLE_SEMANTIC_VIEWS": "1"}):
+ views = discover_semantic_views(mock_engine)
+
+ assert views == []
+ # Engine should not be accessed
+ mock_engine.connect.assert_not_called()
+
+ @patch_sqlalchemy_engine
+ def test_discover_skips_null_names(self):
+ """Test that rows with null names are skipped."""
+ mock_engine = MagicMock()
+ mock_conn = MagicMock()
+
+ results = [
+ # First call: SHOW SEMANTIC VIEWS with one null name
+ [
+ {"database_name": "DB", "schema_name": "SCH", "name": None},
+ {"database_name": "DB", "schema_name": "SCH", "name": "VIEW1"},
+ ],
+ # Second call: GET_DDL for VIEW1 only
+ [{"col": "DDL1"}],
+ ]
+ call_count = [0]
+
+ def mock_execute(_query):
+ result = MagicMock()
+ current_result = results[call_count[0]]
+ call_count[0] += 1
+
+ if isinstance(current_result, list) and current_result:
+ keys = list(current_result[0].keys())
+ rows = [tuple(r.values()) for r in current_result]
+ else:
+ keys = []
+ rows = []
+
+ result.keys.return_value = keys
+ result.fetchall.return_value = rows
+ return result
+
+ mock_engine.connect.return_value.__enter__ = MagicMock(return_value=mock_conn)
+ mock_engine.connect.return_value.__exit__ = MagicMock(return_value=False)
+ mock_conn.execute.side_effect = mock_execute
+
+ views = discover_semantic_views(mock_engine)
+
+ assert len(views) == 1
+ assert views[0].name == "DB.SCH.VIEW1"
+
+
+class TestSQLAlchemySourceSemanticViews:
+ """Tests for SQLAlchemySource semantic view discovery."""
+
+ def test_discovery_for_snowflake_backend(self):
+ """Test that discovery is called for Snowflake backends."""
+ from querychat._datasource import SQLAlchemySource
+
+ mock_engine = MagicMock()
+ mock_engine.dialect.name = "snowflake"
+ mock_inspector = MagicMock()
+ mock_inspector.has_table.return_value = True
+ mock_inspector.get_columns.return_value = [{"name": "id", "type": MagicMock()}]
+
+ with (
+ patch("querychat._datasource.inspect", return_value=mock_inspector),
+ patch(
+ "querychat._datasource.discover_semantic_views", return_value=[]
+ ) as mock_discover,
+ ):
+ source = SQLAlchemySource(mock_engine, "test_table")
+ mock_discover.assert_not_called()
+
+ # Discovery happens when calling get_semantic_views_description
+ source.get_semantic_views_description()
+
+ mock_discover.assert_called_once_with(mock_engine)
+
+ def test_discovery_skipped_for_non_snowflake(self):
+ """Test that discovery is skipped for non-Snowflake backends."""
+ from querychat._datasource import SQLAlchemySource
+
+ mock_engine = MagicMock()
+ mock_engine.dialect.name = "postgresql"
+ mock_inspector = MagicMock()
+ mock_inspector.has_table.return_value = True
+ mock_inspector.get_columns.return_value = [{"name": "id", "type": MagicMock()}]
+
+ with (
+ patch("querychat._datasource.inspect", return_value=mock_inspector),
+ patch("querychat._datasource.discover_semantic_views") as mock_discover,
+ ):
+ source = SQLAlchemySource(mock_engine, "test_table")
+
+ # For non-Snowflake, discovery is not called
+ source.get_semantic_views_description()
+
+ mock_discover.assert_not_called()
+
+ def test_get_semantic_views_description_includes_views(self):
+ """Test that get_semantic_views_description includes semantic view content."""
+ from querychat._datasource import SQLAlchemySource
+
+ views = [SemanticViewInfo(name="db.schema.metrics", ddl="CREATE SEMANTIC VIEW")]
+
+ mock_engine = MagicMock()
+ mock_engine.dialect.name = "snowflake"
+ mock_inspector = MagicMock()
+ mock_inspector.has_table.return_value = True
+ mock_inspector.get_columns.return_value = [{"name": "id", "type": MagicMock()}]
+
+ with (
+ patch("querychat._datasource.inspect", return_value=mock_inspector),
+ patch(
+ "querychat._datasource.discover_semantic_views",
+ return_value=views,
+ ),
+ ):
+ source = SQLAlchemySource(mock_engine, "test_table")
+ section = source.get_semantic_views_description()
+
+ assert "## Semantic Views" in section
+ assert "db.schema.metrics" in section
+ assert "CREATE SEMANTIC VIEW" in section
+
+ def test_get_semantic_views_description_empty_for_non_snowflake(self):
+ """Test that get_semantic_views_description returns empty for non-Snowflake."""
+ from querychat._datasource import SQLAlchemySource
+
+ mock_engine = MagicMock()
+ mock_engine.dialect.name = "postgresql"
+ mock_inspector = MagicMock()
+ mock_inspector.has_table.return_value = True
+ mock_inspector.get_columns.return_value = [{"name": "id", "type": MagicMock()}]
+
+ with patch("querychat._datasource.inspect", return_value=mock_inspector):
+ source = SQLAlchemySource(mock_engine, "test_table")
+ section = source.get_semantic_views_description()
+
+ assert section == ""
+
+
+class TestIbisSourceSemanticViews:
+ """Tests for IbisSource semantic view discovery."""
+
+ def test_discovery_for_snowflake_backend(self):
+ """Test that discovery runs for Snowflake backends."""
+ from ibis.backends.sql import SQLBackend
+ from querychat._datasource import IbisSource
+
+ mock_table = MagicMock()
+ mock_backend = MagicMock(spec=SQLBackend)
+ mock_backend.name = "snowflake"
+ mock_table.get_backend.return_value = mock_backend
+ mock_schema = MagicMock()
+ mock_dtype = MagicMock()
+ mock_dtype.is_numeric.return_value = True
+ mock_dtype.is_integer.return_value = True
+ mock_schema.items.return_value = [("id", mock_dtype)]
+ mock_schema.names = ["id"]
+ mock_table.schema.return_value = mock_schema
+
+ with patch(
+ "querychat._datasource.discover_semantic_views", return_value=[]
+ ) as mock_discover:
+ source = IbisSource(mock_table, "test")
+ mock_discover.assert_not_called()
+
+ # Discovery happens when calling get_semantic_views_description
+ source.get_semantic_views_description()
+
+ mock_discover.assert_called_once_with(mock_backend)
+
+ def test_discovery_skipped_for_non_snowflake(self):
+ """Test that discovery is skipped for non-Snowflake backends."""
+ from ibis.backends.sql import SQLBackend
+ from querychat._datasource import IbisSource
+
+ mock_table = MagicMock()
+ mock_backend = MagicMock(spec=SQLBackend)
+ mock_backend.name = "postgres"
+ mock_table.get_backend.return_value = mock_backend
+ mock_schema = MagicMock()
+ mock_dtype = MagicMock()
+ mock_dtype.is_numeric.return_value = True
+ mock_dtype.is_integer.return_value = True
+ mock_schema.items.return_value = [("id", mock_dtype)]
+ mock_schema.names = ["id"]
+ mock_table.schema.return_value = mock_schema
+
+ with patch("querychat._datasource.discover_semantic_views") as mock_discover:
+ source = IbisSource(mock_table, "test")
+
+ # For non-Snowflake, discovery is not called
+ source.get_semantic_views_description()
+
+ mock_discover.assert_not_called()
+
+ def test_get_semantic_views_description_includes_views(self):
+ """Test that get_semantic_views_description includes semantic view content."""
+ from ibis.backends.sql import SQLBackend
+ from querychat._datasource import IbisSource
+
+ views = [SemanticViewInfo(name="db.schema.metrics", ddl="CREATE SEMANTIC VIEW")]
+
+ mock_table = MagicMock()
+ mock_backend = MagicMock(spec=SQLBackend)
+ mock_backend.name = "snowflake"
+ mock_table.get_backend.return_value = mock_backend
+ mock_schema = MagicMock()
+ mock_dtype = MagicMock()
+ mock_dtype.is_numeric.return_value = True
+ mock_dtype.is_integer.return_value = True
+ mock_schema.items.return_value = [("id", mock_dtype)]
+ mock_schema.names = ["id"]
+ mock_table.schema.return_value = mock_schema
+
+ with patch(
+ "querychat._datasource.discover_semantic_views",
+ return_value=views,
+ ):
+ source = IbisSource(mock_table, "test_table")
+ section = source.get_semantic_views_description()
+
+ assert "## Semantic Views" in section
+ assert "db.schema.metrics" in section
+ assert "CREATE SEMANTIC VIEW" in section
diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md
index 807ce92f..7594c765 100644
--- a/pkg-r/NEWS.md
+++ b/pkg-r/NEWS.md
@@ -1,5 +1,7 @@
# querychat (development version)
+* Added support for Snowflake Semantic Views. When connected to Snowflake via DBI, querychat automatically discovers available Semantic Views and includes their definitions in the system prompt. This helps the LLM generate correct queries using the `SEMANTIC_VIEW()` table function with certified business metrics and dimensions. (#200)
+
* `QueryChat$new()` now supports deferred data source. Pass `data_source = NULL` at initialization time, then provide the actual data source via the `data_source` parameter of `$server()` or by setting the `$data_source` property. This enables use cases where the data source depends on session-specific authentication or per-user database connections. (#202)
# querychat 0.2.0
diff --git a/pkg-r/R/DBISource.R b/pkg-r/R/DBISource.R
index f27f6ebe..389592d6 100644
--- a/pkg-r/R/DBISource.R
+++ b/pkg-r/R/DBISource.R
@@ -107,6 +107,20 @@ DBISource <- R6::R6Class(
get_schema_impl(private$conn, self$table_name, categorical_threshold)
},
+ #' @description
+ #' Get information about semantic views (if any) for the system prompt.
+ #' @return A string with semantic view information, or empty string if none
+ get_semantic_views_description = function() {
+ if (!is_snowflake_connection(private$conn)) {
+ return("")
+ }
+ views <- discover_semantic_views_impl(private$conn)
+ if (length(views) == 0) {
+ return("")
+ }
+ format_semantic_views(views)
+ },
+
#' @description
#' Execute a SQL query
#'
@@ -380,3 +394,155 @@ r_class_to_sql_type <- function(r_class) {
)
}
# nocov end
+
+# Snowflake Semantic Views Support ----
+
+#' Check if a connection is a Snowflake connection
+#'
+#' @param conn A DBI connection object
+#' @return TRUE if the connection is to Snowflake
+#' @noRd
+is_snowflake_connection <- function(conn) {
+ if (!inherits(conn, "DBIConnection")) {
+ return(FALSE)
+ }
+
+ # Check for known Snowflake connection classes
+ if (inherits(conn, "Snowflake")) {
+ return(TRUE)
+ }
+
+ # Check dbms.name from connection info
+ tryCatch(
+ {
+ conn_info <- DBI::dbGetInfo(conn)
+ dbms_name <- tolower(conn_info[["dbms.name"]] %||% "")
+ grepl("snowflake", dbms_name, ignore.case = TRUE)
+ },
+ error = function(e) FALSE
+ )
+}
+
+#' Discover Semantic Views in Snowflake
+#'
+#' @param conn A DBI connection to Snowflake
+#' @return A list of semantic views with name and ddl
+#' @noRd
+discover_semantic_views_impl <- function(conn) {
+ # Check env var for early exit
+ if (nzchar(Sys.getenv("QUERYCHAT_DISABLE_SEMANTIC_VIEWS", ""))) {
+ return(list())
+ }
+
+ semantic_views <- list()
+
+ # Check for semantic views in the current schema
+ result <- DBI::dbGetQuery(conn, "SHOW SEMANTIC VIEWS")
+
+ if (nrow(result) == 0) {
+ cli::cli_inform(
+ c("i" = "No semantic views found in current schema"),
+ .frequency = "once",
+ .frequency_id = "querychat_no_semantic_views"
+ )
+ return(list())
+ }
+
+ for (i in seq_len(nrow(result))) {
+ row <- result[i, ]
+ view_name <- row[["name"]]
+ database_name <- row[["database_name"]]
+ schema_name <- row[["schema_name"]]
+
+ if (is.null(view_name) || is.na(view_name)) {
+ next
+ }
+
+ # Build fully qualified name
+ fq_name <- paste(database_name, schema_name, view_name, sep = ".")
+
+ # Get the DDL for this semantic view
+ ddl <- get_semantic_view_ddl(conn, fq_name)
+ if (!is.null(ddl)) {
+ semantic_views <- c(
+ semantic_views,
+ list(
+ list(
+ name = fq_name,
+ ddl = ddl
+ )
+ )
+ )
+ }
+ }
+
+ semantic_views
+}
+
+#' Get the DDL for a Semantic View
+#'
+#' @param conn A DBI connection to Snowflake
+#' @param fq_name Fully qualified name (database.schema.view_name)
+#' @return The DDL text, or NULL if retrieval failed
+#' @noRd
+get_semantic_view_ddl <- function(conn, fq_name) {
+ # Escape single quotes to prevent SQL injection
+ safe_name <- gsub("'", "''", fq_name, fixed = TRUE)
+ query <- sprintf("SELECT GET_DDL('SEMANTIC_VIEW', '%s')", safe_name)
+ result <- DBI::dbGetQuery(conn, query)
+ if (nrow(result) > 0 && ncol(result) > 0) {
+ as.character(result[[1, 1]])
+ } else {
+ NULL
+ }
+}
+
+#' Format Semantic View DDLs
+#'
+#' @param semantic_views A list of semantic view info (name and ddl)
+#' @return A formatted string with just the DDL definitions
+#' @noRd
+format_semantic_view_ddls <- function(semantic_views) {
+ lines <- character(0)
+
+ for (sv in semantic_views) {
+ lines <- c(
+ lines,
+ sprintf("### Semantic View: `%s`", sv$name),
+ "",
+ "```sql",
+ sv$ddl,
+ "```",
+ ""
+ )
+ }
+
+ paste(lines, collapse = "\n")
+}
+
+#' Build the complete semantic views section for the prompt
+#'
+#' @param semantic_views A list of semantic view info (name and ddl)
+#' @return A formatted string with the full semantic views section
+#' @noRd
+format_semantic_views <- function(semantic_views) {
+ if (length(semantic_views) == 0) {
+ return("")
+ }
+
+ prompts_dir <- system.file("prompts", "semantic-views", package = "querychat")
+ prompt_text <- readLines(file.path(prompts_dir, "prompt.md"), warn = FALSE)
+ syntax_text <- readLines(file.path(prompts_dir, "syntax.md"), warn = FALSE)
+ ddls_text <- format_semantic_view_ddls(semantic_views)
+
+ paste(
+ paste(prompt_text, collapse = "\n"),
+ "",
+ paste(syntax_text, collapse = "\n"),
+ "",
+ "",
+ ddls_text,
+ "",
+ sep = "\n"
+ )
+}
diff --git a/pkg-r/R/QueryChatSystemPrompt.R b/pkg-r/R/QueryChatSystemPrompt.R
index 52e99fb5..db98e952 100644
--- a/pkg-r/R/QueryChatSystemPrompt.R
+++ b/pkg-r/R/QueryChatSystemPrompt.R
@@ -79,11 +79,19 @@ QueryChatSystemPrompt <- R6::R6Class(
#' @return A character string containing the rendered system prompt.
render = function(tools) {
# Build context for whisker rendering
- is_duck_db <- tolower(self$data_source$get_db_type()) == "duckdb"
+ db_type <- self$data_source$get_db_type()
+ is_duck_db <- tolower(db_type) == "duckdb"
+
+ # Get semantic views description (available with DBISource for Snowflake)
+ semantic_views <- ""
+ if (inherits(self$data_source, "DBISource")) {
+ semantic_views <- self$data_source$get_semantic_views_description()
+ }
context <- list(
- db_type = self$data_source$get_db_type(),
+ db_type = db_type,
is_duck_db = is_duck_db,
+ semantic_views = semantic_views,
schema = self$schema,
data_description = self$data_description,
extra_instructions = self$extra_instructions,
diff --git a/pkg-r/inst/prompts/prompt.md b/pkg-r/inst/prompts/prompt.md
index 7c8ea5a1..8c6ff97b 100644
--- a/pkg-r/inst/prompts/prompt.md
+++ b/pkg-r/inst/prompts/prompt.md
@@ -71,6 +71,7 @@ quantile_cont(salary, 0.5)
```
{{/is_duck_db}}
+{{{semantic_views}}}
## Your Capabilities
You can handle these types of requests:
diff --git a/pkg-r/inst/prompts/semantic-views/prompt.md b/pkg-r/inst/prompts/semantic-views/prompt.md
new file mode 100644
index 00000000..32f448ee
--- /dev/null
+++ b/pkg-r/inst/prompts/semantic-views/prompt.md
@@ -0,0 +1,5 @@
+## Semantic Views
+
+**IMPORTANT**: This database has Semantic Views available. Semantic Views provide a curated layer over raw data with pre-defined metrics, dimensions, and relationships. They encode business logic and calculation rules that ensure consistent, accurate results. When a Semantic View covers the data you need, prefer it over raw tables to benefit from these certified definitions (that is, use the `SEMANTIC_VIEW()` table function where appropriate when generating SQL).
+
+**Real-world example**: A legacy ERP database had a revenue column (`X_AMT`) with hidden business rules—only status code 90 transactions count as realized revenue, and a discount factor (`ADJ_FCTR`) must be applied. Querying raw tables for "external customer revenue" returned **$184B**. The same query using the semantic model's certified `NET_REVENUE` metric returned **$84.5B**—the correct answer. The raw query was **2x+ too high** because it ignored discounts and included invalid transaction codes.
diff --git a/pkg-r/inst/prompts/semantic-views/syntax.md b/pkg-r/inst/prompts/semantic-views/syntax.md
new file mode 100644
index 00000000..32988f38
--- /dev/null
+++ b/pkg-r/inst/prompts/semantic-views/syntax.md
@@ -0,0 +1,96 @@
+### SEMANTIC_VIEW() Query Syntax
+
+#### Basic Syntax
+
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ {view_name}
+ METRICS {logical_table}.{metric_name}
+ DIMENSIONS {logical_table}.{dimension_name}
+ [WHERE {dimension} = 'value'] -- Optional: pre-aggregation filter
+)
+[WHERE {column} = 'value'] -- Optional: post-aggregation filter
+```
+
+#### Key Rules
+
+1. **Use `SEMANTIC_VIEW()` function** - Not direct SELECT FROM the view
+2. **No GROUP BY needed** - Semantic layer handles aggregation via DIMENSIONS
+3. **No JOINs needed within model** - Relationships are pre-defined
+4. **No aggregate functions needed** - Metrics are pre-aggregated
+5. **Use DDL-defined names** - Metrics and dimensions must match the DDL exactly
+
+#### WHERE Clause: Inside vs Outside
+
+- **Inside** (pre-aggregation): Filters base data BEFORE metrics are computed
+- **Outside** (post-aggregation): Filters results AFTER metrics are computed
+
+```sql
+-- Pre-aggregation: only include 'EXT' accounts in the calculation
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+ WHERE REF_ENTITIES.ACC_TYPE_CD = 'EXT'
+)
+
+-- Post-aggregation: compute all, then filter results
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+)
+WHERE NET_REVENUE > 1000000
+```
+
+#### Common Patterns
+
+**Single metric (total):**
+```sql
+SELECT * FROM SEMANTIC_VIEW(MODEL_NAME METRICS T_DATA.NET_REVENUE)
+```
+
+**Metric by dimension:**
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+)
+```
+
+**Multiple metrics and dimensions:**
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE, T_DATA.GROSS_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD, T_DATA.LOG_DT
+)
+ORDER BY LOG_DT ASC
+```
+
+**Time series:**
+```sql
+SELECT * FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS T_DATA.LOG_DT
+)
+ORDER BY LOG_DT ASC
+```
+
+**Join results with other data:**
+```sql
+SELECT sv.*, lookup.category_name
+FROM SEMANTIC_VIEW(
+ MODEL_NAME
+ METRICS T_DATA.NET_REVENUE
+ DIMENSIONS REF_ENTITIES.ACC_TYPE_CD
+) AS sv
+JOIN category_lookup AS lookup ON sv.ACC_TYPE_CD = lookup.code
+```
+
+#### Troubleshooting
+
+- **"Invalid identifier"**: Verify metric/dimension names match exactly what's in the DDL
+- **Syntax error**: Use SEMANTIC_VIEW() function, GROUP BY isn't needed
diff --git a/pkg-r/man/DBISource.Rd b/pkg-r/man/DBISource.Rd
index dd832062..75e61096 100644
--- a/pkg-r/man/DBISource.Rd
+++ b/pkg-r/man/DBISource.Rd
@@ -42,6 +42,7 @@ db_source$cleanup()
\item \href{#method-DBISource-new}{\code{DBISource$new()}}
\item \href{#method-DBISource-get_db_type}{\code{DBISource$get_db_type()}}
\item \href{#method-DBISource-get_schema}{\code{DBISource$get_schema()}}
+\item \href{#method-DBISource-get_semantic_views_description}{\code{DBISource$get_semantic_views_description()}}
\item \href{#method-DBISource-execute_query}{\code{DBISource$execute_query()}}
\item \href{#method-DBISource-test_query}{\code{DBISource$test_query()}}
\item \href{#method-DBISource-get_data}{\code{DBISource$get_data()}}
@@ -107,6 +108,19 @@ A string describing the schema
}
}
\if{html}{\out{
}}
+\if{html}{\out{}}
+\if{latex}{\out{\hypertarget{method-DBISource-get_semantic_views_description}{}}}
+\subsection{Method \code{get_semantic_views_description()}}{
+Get information about semantic views (if any) for the system prompt.
+\subsection{Usage}{
+\if{html}{\out{}}\preformatted{DBISource$get_semantic_views_description()}\if{html}{\out{
}}
+}
+
+\subsection{Returns}{
+A string with semantic view information, or empty string if none
+}
+}
+\if{html}{\out{
}}
\if{html}{\out{}}
\if{latex}{\out{\hypertarget{method-DBISource-execute_query}{}}}
\subsection{Method \code{execute_query()}}{
diff --git a/pkg-r/man/DataFrameSource.Rd b/pkg-r/man/DataFrameSource.Rd
index c16cf245..c1d18815 100644
--- a/pkg-r/man/DataFrameSource.Rd
+++ b/pkg-r/man/DataFrameSource.Rd
@@ -56,6 +56,7 @@ df_sqlite$cleanup()
querychat::DBISource$get_data()
querychat::DBISource$get_db_type()
querychat::DBISource$get_schema()
+querychat::DBISource$get_semantic_views_description()
querychat::DBISource$test_query()
diff --git a/pkg-r/man/TblSqlSource.Rd b/pkg-r/man/TblSqlSource.Rd
index 71e98a6a..4446cc77 100644
--- a/pkg-r/man/TblSqlSource.Rd
+++ b/pkg-r/man/TblSqlSource.Rd
@@ -54,6 +54,13 @@ mtcars_source$cleanup()
\item \href{#method-TblSqlSource-clone}{\code{TblSqlSource$clone()}}
}
}
+\if{html}{\out{
+Inherited methods
+
+
+}}
\if{html}{\out{
}}
\if{html}{\out{}}
\if{latex}{\out{\hypertarget{method-TblSqlSource-new}{}}}
diff --git a/pkg-r/tests/testthat/test-SnowflakeSource.R b/pkg-r/tests/testthat/test-SnowflakeSource.R
new file mode 100644
index 00000000..32217d90
--- /dev/null
+++ b/pkg-r/tests/testthat/test-SnowflakeSource.R
@@ -0,0 +1,129 @@
+# Tests for Snowflake semantic view functionality in DBISource
+
+describe("format_semantic_view_ddls()", {
+ it("formats single semantic view correctly", {
+ views <- list(
+ list(name = "db.schema.view", ddl = "CREATE SEMANTIC VIEW test_view")
+ )
+ result <- format_semantic_view_ddls(views)
+
+ expect_match(result, "db.schema.view")
+ expect_match(result, "CREATE SEMANTIC VIEW test_view")
+ expect_match(result, "```sql")
+ })
+
+ it("formats multiple views", {
+ views <- list(
+ list(name = "db.schema.view1", ddl = "CREATE SEMANTIC VIEW v1"),
+ list(name = "db.schema.view2", ddl = "CREATE SEMANTIC VIEW v2")
+ )
+ result <- format_semantic_view_ddls(views)
+
+ expect_match(result, "db.schema.view1")
+ expect_match(result, "db.schema.view2")
+ expect_match(result, "CREATE SEMANTIC VIEW v1")
+ expect_match(result, "CREATE SEMANTIC VIEW v2")
+ })
+})
+
+describe("format_semantic_views()", {
+ it("includes IMPORTANT notice", {
+ views <- list(
+ list(name = "test", ddl = "DDL")
+ )
+ result <- format_semantic_views(views)
+ expect_match(result, "\\*\\*IMPORTANT\\*\\*")
+ })
+
+ it("includes section header", {
+ views <- list(
+ list(name = "test", ddl = "DDL")
+ )
+ result <- format_semantic_views(views)
+ expect_match(result, "## Semantic Views")
+ })
+
+ it("returns empty string for empty views list", {
+ result <- format_semantic_views(list())
+ expect_equal(result, "")
+ })
+})
+
+describe("SQL escaping in get_semantic_view_ddl()", {
+ it("escapes single quotes in view names", {
+ # We can't test the full function without a Snowflake connection,
+ # but we can test the escaping logic directly
+ fq_name <- "db.schema.test'view"
+ safe_name <- gsub("'", "''", fq_name, fixed = TRUE)
+
+ expect_equal(safe_name, "db.schema.test''view")
+ })
+
+ it("leaves normal names unchanged", {
+ fq_name <- "db.schema.normal_view"
+ safe_name <- gsub("'", "''", fq_name, fixed = TRUE)
+
+ expect_equal(safe_name, "db.schema.normal_view")
+ })
+})
+
+describe("is_snowflake_connection()", {
+ it("returns FALSE for non-Snowflake connections", {
+ skip_if_not_installed("RSQLite")
+
+ conn <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
+ withr::defer(DBI::dbDisconnect(conn))
+
+ expect_false(is_snowflake_connection(conn))
+ })
+
+ it("returns FALSE for non-DBI objects", {
+ expect_false(is_snowflake_connection(NULL))
+ expect_false(is_snowflake_connection("not a connection"))
+ expect_false(is_snowflake_connection(list(fake = "connection")))
+ expect_false(is_snowflake_connection(123))
+ })
+})
+
+describe("DBISource semantic views", {
+ it("get_semantic_views_description() returns empty for non-Snowflake", {
+ skip_if_not_installed("RSQLite")
+
+ conn <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
+ withr::defer(DBI::dbDisconnect(conn))
+ DBI::dbWriteTable(conn, "test_table", data.frame(x = 1:3))
+
+ source <- DBISource$new(conn, "test_table")
+ expect_equal(source$get_semantic_views_description(), "")
+ })
+})
+
+describe("discover_semantic_views_impl()", {
+ it("propagates errors (not swallowed)", {
+ skip_if_not_installed("RSQLite")
+
+ # SQLite doesn't have SHOW SEMANTIC VIEWS, so it should error
+ conn <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
+ withr::defer(DBI::dbDisconnect(conn))
+
+ # Without tryCatch wrapping, this should error (not return empty list)
+ # The error is a syntax error since SQLite doesn't support SHOW command
+ expect_error(
+ discover_semantic_views_impl(conn),
+ "SHOW"
+ )
+ })
+
+ it("respects QUERYCHAT_DISABLE_SEMANTIC_VIEWS env var", {
+ skip_if_not_installed("RSQLite")
+
+ conn <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
+ withr::defer(DBI::dbDisconnect(conn))
+
+ withr::with_envvar(c("QUERYCHAT_DISABLE_SEMANTIC_VIEWS" = "1"), {
+ # Should return empty list without querying (no error from SQLite)
+ result <- discover_semantic_views_impl(conn)
+ expect_equal(result, list())
+ })
+ })
+})