diff --git a/mindsdb/integrations/handlers/multi_format_api_handler/format_parsers.py b/mindsdb/integrations/handlers/multi_format_api_handler/format_parsers.py index 9b46ab61da9..ffa297758ad 100644 --- a/mindsdb/integrations/handlers/multi_format_api_handler/format_parsers.py +++ b/mindsdb/integrations/handlers/multi_format_api_handler/format_parsers.py @@ -181,7 +181,25 @@ def parse_json(content: str, record_path: Optional[str] = None, auto_explode: bo if records is None: return pd.DataFrame({'value': [data]}) if len(records) == 0: - return pd.DataFrame() + # Empty record array (e.g. {"results": [], "next_url": "..."}). A bare + # pd.DataFrame() is shape (0, 0) — zero columns — which DuckDB rejects + # downstream with "Need a DataFrame with at least one column" when the + # planner runs `SELECT * FROM df`. That surfaces to the agent as an opaque + # DuckDB error and triggers pointless query-rewrite retries, when the + # query was in fact valid and simply matched no rows. + # + # Preserve the envelope's top-level scalar siblings as columns so the + # empty result still carries metadata (status, count, next_url, ...); + # fall back to a single sentinel column so DuckDB always has a schema + # to describe. + columns = [] + if isinstance(data, dict): + top_key = chosen_path.split('.')[0] if chosen_path else None + columns = [ + k for k, v in data.items() + if k != top_key and (v is None or isinstance(v, (str, int, float, bool))) + ] + return pd.DataFrame(columns=columns or ['_empty']) df = pd.json_normalize(records, sep='.') diff --git a/mindsdb/integrations/handlers/multi_format_api_handler/tests/test_format_parsers.py b/mindsdb/integrations/handlers/multi_format_api_handler/tests/test_format_parsers.py index 4c2e2183ecd..ce97644fc9e 100644 --- a/mindsdb/integrations/handlers/multi_format_api_handler/tests/test_format_parsers.py +++ b/mindsdb/integrations/handlers/multi_format_api_handler/tests/test_format_parsers.py @@ -151,6 +151,32 @@ def test_empty_array_returns_empty_dataframe(): df = parse_json(_dumps(payload)) assert len(df) == 0 + # Must never be a (0, 0) frame: DuckDB's `SELECT * FROM df` requires at + # least one column, else it raises "Need a DataFrame with at least one + # column" and the agent misreads a valid empty result as a bad query. + assert df.shape[1] >= 1 + + +def test_empty_array_preserves_envelope_scalar_columns(): + # Regression: {"results": [], "next_url": "..."} previously produced a + # (0, 0) DataFrame, crashing DuckDB downstream. It must now yield an empty + # frame whose columns are the envelope's scalar siblings. + payload = {"results": [], "next_url": "https://api.example.com/next", "count": 0} + df = parse_json(_dumps(payload)) + + assert len(df) == 0 + assert "next_url" in df.columns + assert "count" in df.columns + assert "results" not in df.columns + + +def test_empty_top_level_array_falls_back_to_sentinel_column(): + # A bare empty list has no envelope metadata to borrow columns from, so it + # falls back to a sentinel column to keep DuckDB happy. + df = parse_json(_dumps([])) + + assert len(df) == 0 + assert df.shape[1] >= 1 def test_primitive_wrapped_in_value_column():