Skip to content

Commit 81b4cc6

Browse files
committed
Add SQL view tab to workbench and fix duplicate columns in CTEs
- Add SQL tab to results panel showing rendered SQL query - Fix duplicate column issue in CTE generation (e.g., id AS id, id AS id) - Track all columns added to CTEs to prevent duplicates from PKs/FKs/dimensions - Update theme support for SQL display area
1 parent af039c5 commit 81b4cc6

2 files changed

Lines changed: 120 additions & 19 deletions

File tree

sidemantic/cli.py

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ class SidequeryWorkbench(App):
110110
display: none;
111111
}
112112
113+
#sql-view {
114+
height: 1fr;
115+
display: none;
116+
}
117+
118+
#sql-display {
119+
height: 100%;
120+
}
121+
113122
#results-table {
114123
height: 100%;
115124
}
@@ -146,6 +155,7 @@ def __init__(self, directory: Path, show_sql: bool = False, demo_mode: bool = Fa
146155
self.directory = directory
147156
self.layer = None
148157
self.last_result = None
158+
self.last_rendered_sql = None
149159
self.demo_mode = demo_mode
150160

151161
def compose(self) -> ComposeResult:
@@ -193,8 +203,11 @@ def compose(self) -> ComposeResult:
193203
with Horizontal(id="view-buttons"):
194204
yield Button("Table", id="btn-table", classes="active")
195205
yield Button("Chart", id="btn-chart")
206+
yield Button("SQL", id="btn-sql")
196207
with Vertical(id="table-view"):
197208
yield DataTable(id="results-table")
209+
with Vertical(id="sql-view"):
210+
yield TextArea("", language="sql", show_line_numbers=True, read_only=True, id="sql-display")
198211
with Vertical(id="chart-view"):
199212
with Horizontal(id="chart-config", classes="config-row"):
200213
yield Label("X:", classes="config-label")
@@ -234,6 +247,13 @@ def watch_theme(self, theme_name: str) -> None:
234247
for editor in self.query(".sql-editor").results(TextArea):
235248
editor.theme = editor_theme
236249

250+
# Update SQL display
251+
try:
252+
sql_display = self.query_one("#sql-display", TextArea)
253+
sql_display.theme = editor_theme
254+
except Exception:
255+
pass # SQL display may not exist yet
256+
237257
def on_mount(self) -> None:
238258
"""Load semantic layer and populate tree."""
239259
# Show first query editor
@@ -243,16 +263,41 @@ def on_mount(self) -> None:
243263
# Setup database connection
244264
if self.demo_mode:
245265
# Create in-memory demo database
246-
from sidemantic.examples.multi_format_demo.demo_data import create_demo_database
266+
try:
267+
# Try packaged import first
268+
from sidemantic.examples.multi_format_demo.demo_data import create_demo_database
269+
except ModuleNotFoundError:
270+
# Fall back to dev environment import
271+
import sys
272+
demo_data_path = self.directory / "demo_data.py"
273+
if demo_data_path.exists():
274+
import importlib.util
275+
spec = importlib.util.spec_from_file_location("demo_data", demo_data_path)
276+
demo_data_module = importlib.util.module_from_spec(spec)
277+
sys.modules["demo_data"] = demo_data_module
278+
spec.loader.exec_module(demo_data_module)
279+
create_demo_database = demo_data_module.create_demo_database
280+
else:
281+
raise ImportError(f"Could not find demo_data.py at {demo_data_path}")
247282

248283
# Create layer with in-memory DB
249284
self.layer = SemanticLayer(connection="duckdb:///:memory:")
250285
# Populate with demo data
251286
demo_conn = create_demo_database()
252287
# Copy data from demo connection to layer's connection
253288
for table in ["customers", "products", "orders"]:
254-
table_data = demo_conn.execute(f"SELECT * FROM {table}").df() # noqa: F841
255-
self.layer.conn.execute(f"CREATE TABLE {table} AS SELECT * FROM table_data")
289+
# Get table data as regular Python objects (no pandas)
290+
rows = demo_conn.execute(f"SELECT * FROM {table}").fetchall()
291+
columns = [desc[0] for desc in demo_conn.execute(f"SELECT * FROM {table} LIMIT 0").description]
292+
293+
# Create table in target connection
294+
create_sql = demo_conn.execute(f"SELECT sql FROM duckdb_tables() WHERE table_name = '{table}'").fetchone()[0]
295+
self.layer.conn.execute(create_sql)
296+
297+
# Insert data if there are rows
298+
if rows:
299+
placeholders = ", ".join(["?" for _ in columns])
300+
self.layer.conn.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows)
256301
else:
257302
# Try to find database file
258303
db_path = None
@@ -487,8 +532,17 @@ def action_run_query(self) -> None:
487532
if not sql:
488533
return
489534

490-
# Execute query
491-
result = self.layer.sql(sql)
535+
# Execute query and get rendered SQL
536+
from sidemantic.sql.query_rewriter import QueryRewriter
537+
538+
rewriter = QueryRewriter(self.layer.graph, dialect=self.layer.dialect)
539+
rendered_sql = rewriter.rewrite(sql)
540+
541+
# Store rendered SQL
542+
self.last_rendered_sql = rendered_sql
543+
544+
# Execute the query
545+
result = self.layer.conn.execute(rendered_sql)
492546

493547
# Get column names and rows
494548
columns = [desc[0] for desc in result.description]
@@ -497,6 +551,11 @@ def action_run_query(self) -> None:
497551
# Store for chart rendering
498552
self.last_result = {"columns": columns, "rows": rows}
499553

554+
# Update SQL display
555+
if rendered_sql:
556+
sql_display = self.query_one("#sql-display", TextArea)
557+
sql_display.text = rendered_sql
558+
500559
# Update table
501560
table.clear(columns=True)
502561
for col in columns:
@@ -628,20 +687,32 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
628687
else:
629688
btn.remove_class("active")
630689

631-
# Handle table/chart view switching
690+
# Handle table/chart/sql view switching
632691
table_view = self.query_one("#table-view")
633692
chart_view = self.query_one("#chart-view")
693+
sql_view = self.query_one("#sql-view")
634694

635695
if event.button.id == "btn-table":
636696
table_view.styles.display = "block"
637697
chart_view.styles.display = "none"
698+
sql_view.styles.display = "none"
638699
self.query_one("#btn-table", Button).add_class("active")
639700
self.query_one("#btn-chart", Button).remove_class("active")
701+
self.query_one("#btn-sql", Button).remove_class("active")
640702
elif event.button.id == "btn-chart":
641703
table_view.styles.display = "none"
642704
chart_view.styles.display = "block"
705+
sql_view.styles.display = "none"
643706
self.query_one("#btn-table", Button).remove_class("active")
644707
self.query_one("#btn-chart", Button).add_class("active")
708+
self.query_one("#btn-sql", Button).remove_class("active")
709+
elif event.button.id == "btn-sql":
710+
table_view.styles.display = "none"
711+
chart_view.styles.display = "none"
712+
sql_view.styles.display = "block"
713+
self.query_one("#btn-table", Button).remove_class("active")
714+
self.query_one("#btn-chart", Button).remove_class("active")
715+
self.query_one("#btn-sql", Button).add_class("active")
645716

646717
def on_select_changed(self, event: Select.Changed) -> None:
647718
"""Handle chart axis selection changes."""
@@ -1144,15 +1215,41 @@ def mcp_serve(
11441215

11451216
# If demo mode, populate with demo data
11461217
if demo:
1147-
from sidemantic.examples.multi_format_demo.demo_data import create_demo_database
1218+
try:
1219+
# Try packaged import first
1220+
from sidemantic.examples.multi_format_demo.demo_data import create_demo_database
1221+
except ModuleNotFoundError:
1222+
# Fall back to dev environment import
1223+
import sys
1224+
import importlib.util
1225+
demo_data_path = directory / "demo_data.py"
1226+
if demo_data_path.exists():
1227+
spec = importlib.util.spec_from_file_location("demo_data", demo_data_path)
1228+
demo_data_module = importlib.util.module_from_spec(spec)
1229+
sys.modules["demo_data"] = demo_data_module
1230+
spec.loader.exec_module(demo_data_module)
1231+
create_demo_database = demo_data_module.create_demo_database
1232+
else:
1233+
raise ImportError(f"Could not find demo_data.py at {demo_data_path}")
1234+
11481235
from sidemantic.mcp_server import get_layer
11491236

11501237
layer = get_layer()
11511238
demo_conn = create_demo_database()
11521239
# Copy data from demo connection to layer's connection
11531240
for table in ["customers", "products", "orders"]:
1154-
table_data = demo_conn.execute(f"SELECT * FROM {table}").df() # noqa: F841
1155-
layer.conn.execute(f"CREATE TABLE {table} AS SELECT * FROM table_data")
1241+
# Get table data as regular Python objects (no pandas)
1242+
rows = demo_conn.execute(f"SELECT * FROM {table}").fetchall()
1243+
columns = [desc[0] for desc in demo_conn.execute(f"SELECT * FROM {table} LIMIT 0").description]
1244+
1245+
# Create table in target connection
1246+
create_sql = demo_conn.execute(f"SELECT sql FROM duckdb_tables() WHERE table_name = '{table}'").fetchone()[0]
1247+
layer.conn.execute(create_sql)
1248+
1249+
# Insert data if there are rows
1250+
if rows:
1251+
placeholders = ", ".join(["?" for _ in columns])
1252+
layer.conn.executemany(f"INSERT INTO {table} VALUES ({placeholders})", rows)
11561253

11571254
typer.echo(f"Starting MCP server for: {directory}", err=True)
11581255
if db_path and db_path != ":memory:":

sidemantic/sql/generator.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -478,21 +478,21 @@ def _build_model_cte(
478478
# Build SELECT columns
479479
select_cols = []
480480

481-
# Add join keys
482-
join_keys_added = set()
481+
# Track all columns added (not just join keys) to avoid duplicates
482+
columns_added = set()
483483

484484
# Include this model's primary key
485-
if model.primary_key and model.primary_key not in join_keys_added:
485+
if model.primary_key and model.primary_key not in columns_added:
486486
select_cols.append(f"{model.primary_key} AS {model.primary_key}")
487-
join_keys_added.add(model.primary_key)
487+
columns_added.add(model.primary_key)
488488

489489
# Include foreign keys from belongs_to joins
490490
for relationship in model.relationships:
491491
if relationship.type == "many_to_one":
492492
fk = relationship.sql_expr
493-
if fk not in join_keys_added:
493+
if fk not in columns_added:
494494
select_cols.append(f"{fk} AS {fk}")
495-
join_keys_added.add(fk)
495+
columns_added.add(fk)
496496

497497
# Check if other models have has_many/has_one pointing to this model
498498
for other_model_name, other_model in self.graph.models.items():
@@ -504,14 +504,16 @@ def _build_model_cte(
504504
# Other model expects this model to have a foreign key
505505
# For has_many/has_one, foreign_key is the FK column in THIS model
506506
fk = other_join.foreign_key or other_join.sql_expr
507-
if fk not in join_keys_added:
507+
if fk not in columns_added:
508508
select_cols.append(f"{fk} AS {fk}")
509-
join_keys_added.add(fk)
509+
columns_added.add(fk)
510510

511511
# Add dimension columns
512512
# First, add all dimensions from this model (needed for filters/joins)
513513
for dimension in model.dimensions:
514-
select_cols.append(f"{dimension.sql_expr} AS {dimension.name}")
514+
if dimension.name not in columns_added:
515+
select_cols.append(f"{dimension.sql_expr} AS {dimension.name}")
516+
columns_added.add(dimension.name)
515517

516518
# Then, add time dimensions with specific granularities
517519
for dim_ref, gran in dimensions:
@@ -528,7 +530,9 @@ def _build_model_cte(
528530
# Apply time granularity (in addition to base column)
529531
dim_sql = dimension.with_granularity(gran)
530532
alias = f"{dim_name}__{gran}"
531-
select_cols.append(f"{dim_sql} AS {alias}")
533+
if alias not in columns_added:
534+
select_cols.append(f"{dim_sql} AS {alias}")
535+
columns_added.add(alias)
532536

533537
# Add measure columns (raw, not aggregated in CTE)
534538
# Collect all measures needed for metrics

0 commit comments

Comments
 (0)