Skip to content

Commit d7ca5cb

Browse files
authored
display plain output for explain queries in duckdb (#6439)
## 📝 Summary <!-- Provide a concise summary of what this pull request is addressing. If this PR fixes any issues, list them here by number (e.g., Fixes #123). --> polars: <img width="526" height="344" alt="CleanShot 2025-09-19 at 18 12 33" src="https://github.com/user-attachments/assets/9dbd621e-db1e-464e-9143-a73ba4e0fe75" /> pandas: <img width="309" height="235" alt="CleanShot 2025-09-19 at 18 13 22" src="https://github.com/user-attachments/assets/4749035b-8857-45f6-b6dc-5ff34bc5ef1e" /> ## 🔍 Description of Changes <!-- Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily. --> ## 📋 Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] I have added tests for the changes made. - [x] I have run the code and verified that it works as expected.
1 parent 079ac6a commit d7ca5cb

File tree

3 files changed

+205
-2
lines changed

3 files changed

+205
-2
lines changed

marimo/_sql/sql.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
from marimo._sql.engines.sqlalchemy import SQLAlchemyEngine
1313
from marimo._sql.engines.types import QueryEngine
1414
from marimo._sql.get_engines import SUPPORTED_ENGINES
15-
from marimo._sql.utils import raise_df_import_error
15+
from marimo._sql.utils import (
16+
extract_explain_content,
17+
is_explain_query,
18+
raise_df_import_error,
19+
)
1620
from marimo._types.ids import VariableName
1721
from marimo._utils.narwhals_utils import can_narwhalify_lazyframe
1822

@@ -103,9 +107,14 @@ def sql(
103107
raise_df_import_error("polars[pyarrow]")
104108

105109
if output:
110+
from marimo._plugins.stateless.plain_text import plain_text
106111
from marimo._plugins.ui._impl import table
107112

108-
if can_narwhalify_lazyframe(df):
113+
if isinstance(sql_engine, DuckDBEngine) and is_explain_query(query):
114+
# For EXPLAIN queries in DuckDB, display plain output to preserve box drawings
115+
text_output = extract_explain_content(df)
116+
t = plain_text(text_output)
117+
elif can_narwhalify_lazyframe(df):
109118
# For pl.LazyFrame and DuckDBRelation, we only show the first few rows
110119
# to avoid loading all the data into memory.
111120
# Also preload the first page of data without user confirmation.

marimo/_sql/utils.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,44 @@ def sql_type_to_data_type(type_str: str) -> DataType:
176176
return "string"
177177
else:
178178
return "string"
179+
180+
181+
def is_explain_query(query: str) -> bool:
182+
"""Check if a SQL query is an EXPLAIN query."""
183+
return query.lstrip().lower().startswith("explain ")
184+
185+
186+
def extract_explain_content(df: Any) -> str:
187+
"""Extract all content from a DataFrame for EXPLAIN queries.
188+
189+
Args:
190+
df: DataFrame (pandas or polars). If not pandas / polars, return repr(df).
191+
192+
Returns:
193+
String containing content of dataframe
194+
"""
195+
try:
196+
if DependencyManager.polars.has():
197+
import polars as pl
198+
199+
if isinstance(df, pl.LazyFrame):
200+
df = df.collect()
201+
if isinstance(df, pl.DataFrame):
202+
# Display full strings without truncation
203+
with pl.Config(fmt_str_lengths=1000):
204+
return str(df)
205+
206+
if DependencyManager.pandas.has():
207+
import pandas as pd
208+
209+
if isinstance(df, pd.DataFrame):
210+
# Preserve newlines in the data
211+
all_values = df.values.flatten().tolist()
212+
return "\n".join(str(val) for val in all_values)
213+
214+
# Fallback to repr for other types
215+
return repr(df)
216+
217+
except Exception as e:
218+
LOGGER.debug("Failed to extract explain content: %s", e)
219+
return repr(df)

tests/_sql/test_sql.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from marimo._sql.engines.ibis import IbisEngine
1414
from marimo._sql.engines.sqlalchemy import SQLAlchemyEngine
1515
from marimo._sql.sql import _query_includes_limit, sql
16+
from marimo._sql.utils import extract_explain_content, is_explain_query
1617

1718
if TYPE_CHECKING:
1819
from collections.abc import Generator
@@ -360,3 +361,155 @@ def test_sql_with_ibis_expression_result():
360361
with patch.object(IbisEngine, "sql_output_format", return_value="native"):
361362
result = sql("SELECT * FROM test", engine=duckdb_backend)
362363
assert isinstance(result, Expr)
364+
365+
366+
class TestExplainQueries:
367+
def test_is_explain_query(self):
368+
"""Test is_explain_query function."""
369+
# Test valid EXPLAIN queries
370+
assert is_explain_query("EXPLAIN SELECT 1")
371+
assert is_explain_query("explain SELECT 1")
372+
assert is_explain_query(" EXPLAIN SELECT 1")
373+
assert is_explain_query("\tEXPLAIN SELECT 1")
374+
assert is_explain_query("\nEXPLAIN SELECT 1")
375+
assert is_explain_query("EXPLAIN (FORMAT JSON) SELECT 1")
376+
assert is_explain_query("EXPLAIN ANALYZE SELECT 1")
377+
assert is_explain_query("EXPLAIN QUERY PLAN SELECT 1")
378+
379+
# Test non-EXPLAIN queries
380+
assert not is_explain_query("SELECT 1")
381+
assert not is_explain_query("INSERT INTO t VALUES (1)")
382+
assert not is_explain_query("UPDATE t SET col = 1")
383+
assert not is_explain_query("DELETE FROM t")
384+
assert not is_explain_query("CREATE TABLE t (id INT)")
385+
assert not is_explain_query("")
386+
assert not is_explain_query(" ")
387+
388+
# Test edge cases
389+
assert not is_explain_query("EXPLAINED") # Not exactly "explain"
390+
assert not is_explain_query("EXPLAINING") # Not exactly "explain"
391+
assert not is_explain_query(
392+
"-- EXPLAIN SELECT 1"
393+
) # Comment, not actual query
394+
395+
@pytest.fixture
396+
def explain_df_data(self) -> dict[str, list[str]]:
397+
return {
398+
"explain_key": ["physical_plan", "logical_plan"],
399+
"explain_value": [
400+
"┌─────────────────────────────────────┐\n│ PROJECTION │\n└─────────────────────────────────────┘",
401+
"┌─────────────────────────────────────┐\n│ SELECTION │\n└─────────────────────────────────────┘",
402+
],
403+
}
404+
405+
@pytest.mark.skipif(not HAS_POLARS, reason="Polars not installed")
406+
def test_extract_explain_content_polars(
407+
self, explain_df_data: dict[str, list[str]]
408+
):
409+
"""Test extract_explain_content with polars DataFrames."""
410+
import polars as pl
411+
412+
# Test with regular DataFrame
413+
df = pl.DataFrame(explain_df_data)
414+
415+
expected_rendering = """shape: (2, 2)
416+
┌───────────────┬───────────────────────────────────────────┐
417+
│ explain_key ┆ explain_value │
418+
│ --- ┆ --- │
419+
│ str ┆ str │
420+
╞═══════════════╪═══════════════════════════════════════════╡
421+
│ physical_plan ┆ ┌─────────────────────────────────────┐ │
422+
│ ┆ │ PROJECTION │ │
423+
│ ┆ └─────────────────────────────────────┘ │
424+
│ logical_plan ┆ ┌─────────────────────────────────────┐ │
425+
│ ┆ │ SELECTION │ │
426+
│ ┆ └─────────────────────────────────────┘ │
427+
└───────────────┴───────────────────────────────────────────┘"""
428+
429+
result = extract_explain_content(df)
430+
assert result == expected_rendering
431+
432+
# Test with LazyFrame
433+
lazy_df = df.lazy()
434+
result = extract_explain_content(lazy_df)
435+
assert result == expected_rendering
436+
437+
@pytest.mark.skipif(not HAS_PANDAS, reason="Pandas not installed")
438+
def test_extract_explain_content_pandas(
439+
self, explain_df_data: dict[str, list[str]]
440+
):
441+
"""Test extract_explain_content with pandas DataFrames."""
442+
import pandas as pd
443+
444+
df = pd.DataFrame(explain_df_data)
445+
446+
result = extract_explain_content(df)
447+
assert (
448+
result
449+
== """physical_plan
450+
┌─────────────────────────────────────┐
451+
│ PROJECTION │
452+
└─────────────────────────────────────┘
453+
logical_plan
454+
┌─────────────────────────────────────┐
455+
│ SELECTION │
456+
└─────────────────────────────────────┘"""
457+
)
458+
459+
def test_extract_explain_content_fallback(self):
460+
"""Test extract_explain_content fallback for non-DataFrame objects."""
461+
# Test with non-DataFrame object
462+
result = extract_explain_content("not a dataframe")
463+
assert isinstance(result, str)
464+
assert "not a dataframe" in result
465+
466+
# Test with None
467+
result = extract_explain_content(None)
468+
assert isinstance(result, str)
469+
assert "None" in result
470+
471+
@pytest.mark.skipif(not HAS_POLARS, reason="Polars not installed")
472+
def test_extract_explain_content_error_handling(self):
473+
"""Test extract_explain_content error handling."""
474+
475+
# Create a mock DataFrame that will raise an error
476+
class MockDataFrame:
477+
def __init__(self):
478+
pass
479+
480+
def __str__(self):
481+
raise RuntimeError("Test error")
482+
483+
mock_df = MockDataFrame()
484+
result = extract_explain_content(mock_df)
485+
assert isinstance(result, str)
486+
assert "MockDataFrame" in result # Should fallback to repr
487+
488+
@patch("marimo._sql.sql.replace")
489+
@pytest.mark.skipif(
490+
not HAS_POLARS or not HAS_DUCKDB, reason="polars and duckdb required"
491+
)
492+
def test_sql_explain_query_display(self, mock_replace):
493+
"""Test that EXPLAIN queries are displayed as plain text."""
494+
import duckdb
495+
import polars as pl
496+
497+
# Create a test table
498+
duckdb.sql(
499+
"CREATE OR REPLACE TABLE test_explain AS SELECT * FROM range(5)"
500+
)
501+
502+
# Test EXPLAIN query
503+
result = sql("EXPLAIN SELECT * FROM test_explain")
504+
assert isinstance(result, pl.DataFrame)
505+
506+
# Should call replace with plain_text
507+
mock_replace.assert_called_once()
508+
call_args = mock_replace.call_args[0][0]
509+
510+
# The call should be a plain_text object
511+
assert hasattr(call_args, "text")
512+
assert isinstance(call_args.text, str)
513+
514+
# Clean up
515+
duckdb.sql("DROP TABLE test_explain")

0 commit comments

Comments
 (0)