Repository: marimo-team/marimo
Pull request: fix/improvement: add cursor introspection for dbapi spec
## 📝 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).
-->
Fixes #5541 . Adds introspection for cursor objects when sql output is native.

![CleanShot 2025-07-07 at 15 12 01](https://github.com/user-attachments/assets/f22f112c-d052-40b5-8c41-63f713996f4e)

Also a fix for datasource panel, since the call is async, the loading state does not show.

## 🔍 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.

## 📜 Reviewers

<!--
Tag potential reviewers from the community or maintainers who might be interested in reviewing this pull request.

Your PR will be reviewed more quickly if you can figure out the right person to tag with @

@mscolnick (General, AI)
@dmadisetti (Runtime, Caching, Fileformat, AST)
@manzt (Widgets, Dependency Management, LSP)
@light2dark (Tables, Plots, Layouts, SQL)
@akshayka (Public API, Dependencies, UX/Styling, Backend, Docs, Integrations)
 -->


Problem statement:
TypeError: object of type 'SnowflakeCursor' has no len()
### Describe the bug

When running a SQL cell on a Snowflake connection with SQL Output Type = `Native`.

NB: works fine in SQL Output Type = `Auto`.

```
Traceback (most recent call last):
  File "/Users/tekumara/code/genie/.venv/lib/python3.11/site-packages/marimo/_runtime/executor.py", line 138, in execute_cell
    exec(cell.body, glbls)
  Cell 
marimo:///Users/tekumara/code/genie/notebooks/gspans.py#cell=cell-2

, line 1, in <module>
    messages = mo.sql(
               ^^^^^^^
  File "/Users/tekumara/code/genie/.venv/lib/python3.11/site-packages/marimo/_sql/sql.py", line 122, in sql
    t = table.table(
        ^^^^^^^^^^^^
  File "/Users/tekumara/code/genie/.venv/lib/python3.11/site-packages/marimo/_plugins/ui/_impl/table.py", line 442, in __init__
    num_rows = self._manager.get_num_rows(force=True)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/tekumara/code/genie/.venv/lib/python3.11/site-packages/marimo/_plugins/ui/_impl/tables/default_table.py", line 339, in get_num_rows
    return len(self.data)
           ^^^^^^^^^^^^^^
TypeError: object of type 'SnowflakeCursor' has no len()
```

### Will you submit a PR?

- [ ] Yes

### Environment

<details>

```
{
  "marimo": "0.14.10",
  "OS": "Darwin",
  "OS Version": "24.5.0",
  "Processor": "arm",
  "Python Version": "3.11.11",
  "Binaries": {
    "Browser": "138.0.7204.51",
    "Node": "v20.18.0"
  },
  "Dependencies": {
    "click": "8.2.1",
    "docutils": "0.21.2",
    "itsdangerous": "2.2.0",
    "jedi": "0.19.2",
    "markdown": "3.8.2",
    "narwhals": "1.45.0",
    "packaging": "25.0",
    "psutil": "7.0.0",
    "pygments": "2.19.2",
    "pymdown-extensions": "10.16",
    "pyyaml": "6.0.2",
    "starlette": "0.46.2",
    "tomlkit": "0.13.3",
    "typing-extensions": "4.14.0",
    "uvicorn": "0.35.0",
    "websockets": "15.0.1"
  },
  "Optional Dependencies": {
    "nbformat": "5.10.4",
    "openai": "1.93.0",
    "pandas": "2.2.3",
    "pyarrow": "18.1.0",
    "loro": "1.5.2",
    "pytest": "8.4.1",
    "ruff": "0.12.1",
    "sqlglot": "26.33.0"
  },
  "Experimental Flags": {}
}
```

</details>


### Code to reproduce

```python
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "marimo",
#     "snowflake-connector-python==3.16.0",
#     "snowflakeutils==1.0.5",
#     "sqlglot==26.33.0",
# ]
# ///
import marimo

__generated_with = "0.14.10"
app = marimo.App(
    width="medium",
    sql_output="native",
)


@app.cell
def _():
    import marimo as mo
    return (mo,)


@app.cell
def _():
    import snowflake.connector
    conn = snowflake.connector.connect(...)
    return (conn,)


@app.cell
def _(conn, mo):
    messages = mo.sql(
        f"""
        select 1
        """,
        engine=conn
    )
    return (messages,)


if __name__ == "__main__":
    app.run()
```

Hints from issue discussion:

Commit message: better comment and add ispending

Patch to review:
diff --git a/frontend/src/components/datasources/datasources.tsx b/frontend/src/components/datasources/datasources.tsx
index 92a28a811ea..ca79c315ceb 100644
--- a/frontend/src/components/datasources/datasources.tsx
+++ b/frontend/src/components/datasources/datasources.tsx
@@ -497,6 +497,7 @@ const DatasetTableItem: React.FC<{
   const { addTable } = useDataSourceActions();
 
   const [isExpanded, setIsExpanded] = React.useState(false);
+  const [isLoading, setIsLoading] = React.useState(false);
   const [tableDetailsRequested, setTableDetailsRequested] =
     React.useState(false);
   const tableDetailsExist = table.columns.length > 0;
@@ -509,6 +510,7 @@ const DatasetTableItem: React.FC<{
       !tableDetailsRequested
     ) {
       setTableDetailsRequested(true);
+      setIsLoading(true);
       const { engine, database, schema } = sqlTableContext;
       const previewTable = await PreviewSQLTable.request({
         engine: engine,
@@ -525,6 +527,7 @@ const DatasetTableItem: React.FC<{
         table: previewTable.table,
         sqlTableContext: sqlTableContext,
       });
+      setIsLoading(false);
     }
   }, [isExpanded, tableDetailsExist]);
 
@@ -585,7 +588,7 @@ const DatasetTableItem: React.FC<{
   };
 
   const renderColumns = () => {
-    if (isPending) {
+    if (isPending || isLoading) {
       return <LoadingState message="Loading columns..." className="pl-12" />;
     }
 
diff --git a/marimo/_sql/engines/dbapi.py b/marimo/_sql/engines/dbapi.py
index 2e58b3eea4d..a1467db8c5b 100644
--- a/marimo/_sql/engines/dbapi.py
+++ b/marimo/_sql/engines/dbapi.py
@@ -144,3 +144,98 @@ def is_compatible(var: Any) -> bool:
 
         except Exception:
             return False
+
+    @staticmethod
+    def is_dbapi_cursor(obj: Any) -> bool:
+        """
+        Return True if obj looks like a DB-API 2.0 cursor.
+        """
+        try:
+            # Required methods
+            has_execute = callable(getattr(obj, "execute", None))
+            # has_executemany = callable(getattr(obj, "executemany", None))
+
+            # At least one fetch method
+            fetch_methods = ("fetchone", "fetchmany", "fetchall")
+            has_fetch = any(
+                callable(getattr(obj, m, None)) for m in fetch_methods
+            )
+
+            # Required attributes (description may be None after DML, but must exist)
+            has_description_attr = hasattr(obj, "description")
+            has_rowcount = hasattr(obj, "rowcount")
+
+            return (
+                has_execute
+                and has_fetch
+                and has_description_attr
+                and has_rowcount
+            )
+        except Exception:
+            return False
+
+    @staticmethod
+    def get_cursor_metadata(cursor: Any) -> dict[str, Any]:
+        """
+        Extract standard DB-API 2.0 cursor metadata.
+        """
+        try:
+            meta: dict[str, Any] = {
+                "result_type": f"{type(cursor)}",
+            }
+
+            # Column info
+            desc = getattr(cursor, "description", None)
+            if desc:
+                cols: list[dict[str, Optional[Any]]] = []
+                for col in desc:
+                    # description tuple: (name, type_code, display_size, internal_size, precision, scale, null_ok)
+                    name = col[0]
+                    type_code = col[1] if len(col) > 1 else None
+                    display_size = col[2] if len(col) > 2 else None
+                    internal_size = col[3] if len(col) > 3 else None
+                    precision = col[4] if len(col) > 4 else None
+                    scale = col[5] if len(col) > 5 else None
+                    null_ok = col[6] if len(col) > 6 else None
+
+                    cols.append(
+                        {
+                            "name": name,
+                            "type_code": type_code,
+                            "display_size": display_size,
+                            "internal_size": internal_size,
+                            "precision": precision,
+                            "scale": scale,
+                            "null_ok": null_ok,
+                        }
+                    )
+                meta["columns"] = cols
+            else:
+                meta["columns"] = None
+
+            if hasattr(cursor, "rowcount"):
+                meta["rowcount"] = cursor.rowcount
+
+            # lastrowid (optional in many drivers)
+            if hasattr(cursor, "lastrowid"):
+                meta["lastrowid"] = cursor.lastrowid
+
+            # SQL type guess
+            # rowcount == -1 usually means SELECT (or DDL), >=0 means DML or SELECT (after execute)
+            rc = getattr(cursor, "rowcount", None)
+            if rc is None:
+                sql_type = "Unknown"
+            elif rc == -1:
+                sql_type = "Query/DDL"
+            else:
+                sql_type = "Query/DML"
+            meta["sql_statement_type"] = sql_type
+
+            return meta
+
+        except Exception:
+            LOGGER.warning("Failed to extract cursor metadata", exc_info=True)
+            return {
+                "result_type": f"{type(cursor)}",
+                "error": "Failed to extract metadata",
+            }
diff --git a/marimo/_sql/sql.py b/marimo/_sql/sql.py
index ee83925e412..8cb97a84fc0 100644
--- a/marimo/_sql/sql.py
+++ b/marimo/_sql/sql.py
@@ -7,7 +7,7 @@
 from marimo._dependencies.dependencies import DependencyManager
 from marimo._output.rich_help import mddoc
 from marimo._runtime.output import replace
-from marimo._sql.engines.dbapi import DBAPIConnection
+from marimo._sql.engines.dbapi import DBAPIConnection, DBAPIEngine
 from marimo._sql.engines.duckdb import DuckDBEngine
 from marimo._sql.engines.sqlalchemy import SQLAlchemyEngine
 from marimo._sql.engines.types import QueryEngine
@@ -118,6 +118,10 @@ def sql(
                 result = SQLAlchemyEngine.get_cursor_metadata(df)
                 if result is not None:
                     display_df = result
+            elif DBAPIEngine.is_dbapi_cursor(df):
+                result = DBAPIEngine.get_cursor_metadata(df)
+                if result is not None:
+                    display_df = result
 
             t = table.table(
                 display_df,
diff --git a/tests/_sql/test_dbapi.py b/tests/_sql/test_dbapi.py
index f2f37e4095d..b9d1b07b4e6 100644
--- a/tests/_sql/test_dbapi.py
+++ b/tests/_sql/test_dbapi.py
@@ -143,3 +143,24 @@ def test_execute_with_parameters(dbapi_engine: DBAPIEngine) -> None:
         )
         assert len(result) == 1
         assert result.iloc[0].to_dict() == {"id": 1, "name": "a", "value": 1.0}
+
+
+def test_is_dbapi_cursor() -> None:
+    cursor = sqlite3.connect(":memory:").cursor()
+    assert DBAPIEngine.is_dbapi_cursor(cursor)
+
+    # Test with non-DBAPI object
+    assert not DBAPIEngine.is_dbapi_cursor("not a cursor")
+    assert not DBAPIEngine.is_dbapi_cursor(None)
+
+
+def test_get_cursor_metadata(dbapi_engine: DBAPIEngine) -> None:
+    with patch.object(
+        dbapi_engine, "sql_output_format", return_value="native"
+    ):
+        result = dbapi_engine.execute("SELECT * FROM test")
+        result = DBAPIEngine.get_cursor_metadata(result)
+
+        assert result is not None
+        assert len(result["columns"]) == 3
+        assert result["sql_statement_type"] == "Query/DDL"


Code review comments:
diff_hunk: @@ -585,7 +588,7 @@ const DatasetTableItem: React.FC<{
   };
 
   const renderColumns = () => {
-    if (isPending) {
+    if (isPending || isLoading) {
path: frontend/src/components/datasources/datasources.tsx
text: @user1:
i think you can use `isFetching` from `useAsyncData` instead of tracking your own in state.