From 163fa99e149805c599e68c0b31a88cf5f8073295 Mon Sep 17 00:00:00 2001 From: Wonjae Lee Date: Tue, 6 Oct 2026 13:29:37 -0500 Subject: [PATCH 1/3] BDX-1369: Send queries list in catalog search requests Newer Dremio search endpoints require the queries list and no longer read the scalar query field. Send both so the CLI works against old and new backends. Co-Authored-By: Claude Opus 5.5 --- src/drs/client.py | 3 ++- tests/test_client.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/drs/client.py b/src/drs/client.py index 5cfc19a..19d590e 100644 --- a/src/drs/client.py +++ b/src/drs/client.py @@ -276,7 +276,8 @@ async def search( max_results: int | None = None, next_page_token: str | None = None, ) -> dict: - body: dict[str, Any] = {"query": query} + # Newer Dremio reads `queries`; older versions read `query`. Both ignore unknown fields. + body: dict[str, Any] = {"queries": [query], "query": query} if filter_: body["filter"] = filter_ if max_results is not None: diff --git a/tests/test_client.py b/tests/test_client.py index 4a38c6f..3197e48 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -119,6 +119,7 @@ async def _capture(request: httpx.Request) -> httpx.Response: await client.search("revenue", filter_='category in ["JOB"]', max_results=20) assert captured["body"] == { + "queries": ["revenue"], "query": "revenue", "filter": 'category in ["JOB"]', "maxResults": 20, @@ -139,10 +140,27 @@ async def _capture(request: httpx.Request) -> httpx.Response: await client.search("revenue", next_page_token="token-123") assert captured["body"] == { + "queries": ["revenue"], "query": "revenue", "pageToken": "token-123", } + @pytest.mark.asyncio + async def test_search_sends_queries_list_and_legacy_query(self, client: DremioClient) -> None: + captured: dict = {} + + async def _capture(request: httpx.Request) -> httpx.Response: + import json + + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"results": []}) + + client._client = httpx.AsyncClient(transport=httpx.MockTransport(_capture)) + + await client.search("") + + assert captured["body"] == {"queries": [""], "query": ""} + class TestSQLBreadcrumb: @pytest.mark.asyncio From 53e3a70b2c89385894870a0c84fa69ce843b1d48 Mon Sep 17 00:00:00 2001 From: Wonjae Lee Date: Tue, 6 Oct 2026 14:46:49 -0500 Subject: [PATCH 2/3] BDX-1369: Support multiple search terms dremio search now accepts one or more terms, sent as the queries list. The legacy query field carries the first non-blank term for older backends. The max term count is enforced by the backend. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +++ src/drs/cli.py | 7 +++++-- src/drs/client.py | 7 ++++--- src/drs/introspect.py | 8 +++++++- tests/test_cli.py | 26 ++++++++++++++++++++++++-- tests/test_client.py | 21 ++++++++++++++++----- 6 files changed, 59 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 681625c..2c18642 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,9 @@ dremio query run "SELECT * FROM myspace.orders LIMIT 5" --output pretty # Search the catalog for anything matching "revenue" dremio search "revenue" +# Search for anything matching either term +dremio search "revenue" "sales" + # Search only jobs and limit the first page size dremio search "revenue" --filter 'category in ["JOB"]' --max-results 20 diff --git a/src/drs/cli.py b/src/drs/cli.py index 6e2f171..ece2758 100644 --- a/src/drs/cli.py +++ b/src/drs/cli.py @@ -171,7 +171,10 @@ def get_client() -> DremioClient: @app.command("search") def search_command( - term: str = typer.Argument(help="Search term (matches table names, view names, source names)"), + terms: list[str] = typer.Argument( + help="One or more search terms (matches table names, view names, source names). " + "Multiple terms are searched as alternatives." + ), filter_: str | None = typer.Option(None, "--filter", help="CEL filter expression to refine search results"), max_results: int | None = typer.Option(None, "--max-results", min=1, help="Maximum results to return per page"), next_page_token: str | None = typer.Option( @@ -194,7 +197,7 @@ async def _execute(): } if next_page_token is not None: search_kwargs["next_page_token"] = next_page_token - return await client.search(term, **search_kwargs) + return await client.search(terms, **search_kwargs) except httpx.HTTPStatusError as exc: raise handle_api_error(exc) from exc finally: diff --git a/src/drs/client.py b/src/drs/client.py index 19d590e..5fca33c 100644 --- a/src/drs/client.py +++ b/src/drs/client.py @@ -271,13 +271,14 @@ async def get_catalog_by_path(self, path_parts: list[str]) -> dict: async def search( self, - query: str, + queries: list[str], filter_: str | None = None, max_results: int | None = None, next_page_token: str | None = None, ) -> dict: - # Newer Dremio reads `queries`; older versions read `query`. Both ignore unknown fields. - body: dict[str, Any] = {"queries": [query], "query": query} + # Newer Dremio reads `queries`; older versions read only `query`. Both ignore unknown fields. + legacy_query = next((q for q in queries if q.strip()), "") + body: dict[str, Any] = {"queries": queries, "query": legacy_query} if filter_: body["filter"] = filter_ if max_results is not None: diff --git a/src/drs/introspect.py b/src/drs/introspect.py index c88d174..b2ee9e3 100644 --- a/src/drs/introspect.py +++ b/src/drs/introspect.py @@ -211,7 +211,13 @@ "mechanism": "REST", "endpoints": ["POST /v0/projects/{pid}/search"], "parameters": [ - {"name": "term", "type": "string", "required": True, "positional": True, "description": "Search term"}, + { + "name": "terms", + "type": "string", + "required": True, + "positional": True, + "description": "One or more search terms, searched as alternatives", + }, { "name": "filter", "type": "string", diff --git a/tests/test_cli.py b/tests/test_cli.py index 85ca6d4..add1c9c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -231,7 +231,7 @@ def test_search_command_passes_filter_and_max_results(monkeypatch) -> None: result = runner.invoke(app, ["search", "revenue", "--filter", 'category in ["JOB"]', "--max-results", "20"]) assert result.exit_code == 0 - search_mock.assert_awaited_once_with("revenue", filter_='category in ["JOB"]', max_results=20) + search_mock.assert_awaited_once_with(["revenue"], filter_='category in ["JOB"]', max_results=20) close_mock.assert_awaited_once() @@ -247,5 +247,27 @@ def test_search_command_passes_next_page_token(monkeypatch) -> None: result = runner.invoke(app, ["search", "revenue", "--next-page-token", "token-123"]) assert result.exit_code == 0 - search_mock.assert_awaited_once_with("revenue", filter_=None, max_results=None, next_page_token="token-123") + search_mock.assert_awaited_once_with(["revenue"], filter_=None, max_results=None, next_page_token="token-123") close_mock.assert_awaited_once() + + +def test_search_command_passes_multiple_terms(monkeypatch) -> None: + search_mock = AsyncMock(return_value={"results": []}) + close_mock = AsyncMock() + client = MagicMock() + client.search = search_mock + client.close = close_mock + + monkeypatch.setattr("drs.cli.get_client", lambda: client) + + result = runner.invoke(app, ["search", "query a", "query b"]) + + assert result.exit_code == 0 + search_mock.assert_awaited_once_with(["query a", "query b"], filter_=None, max_results=None) + close_mock.assert_awaited_once() + + +def test_search_command_requires_a_term() -> None: + result = runner.invoke(app, ["search"]) + + assert result.exit_code != 0 diff --git a/tests/test_client.py b/tests/test_client.py index 3197e48..d140dd7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -116,7 +116,7 @@ async def _capture(request: httpx.Request) -> httpx.Response: client._client = httpx.AsyncClient(transport=httpx.MockTransport(_capture)) - await client.search("revenue", filter_='category in ["JOB"]', max_results=20) + await client.search(["revenue"], filter_='category in ["JOB"]', max_results=20) assert captured["body"] == { "queries": ["revenue"], @@ -137,7 +137,7 @@ async def _capture(request: httpx.Request) -> httpx.Response: client._client = httpx.AsyncClient(transport=httpx.MockTransport(_capture)) - await client.search("revenue", next_page_token="token-123") + await client.search(["revenue"], next_page_token="token-123") assert captured["body"] == { "queries": ["revenue"], @@ -145,8 +145,19 @@ async def _capture(request: httpx.Request) -> httpx.Response: "pageToken": "token-123", } + @pytest.mark.parametrize( + ("queries", "expected_body"), + [ + (["revenue"], {"queries": ["revenue"], "query": "revenue"}), + (["revenue", "sales"], {"queries": ["revenue", "sales"], "query": "revenue"}), + ([" ", "sales"], {"queries": [" ", "sales"], "query": "sales"}), + ([""], {"queries": [""], "query": ""}), + ], + ) @pytest.mark.asyncio - async def test_search_sends_queries_list_and_legacy_query(self, client: DremioClient) -> None: + async def test_search_sends_queries_list_and_legacy_query( + self, client: DremioClient, queries: list[str], expected_body: dict + ) -> None: captured: dict = {} async def _capture(request: httpx.Request) -> httpx.Response: @@ -157,9 +168,9 @@ async def _capture(request: httpx.Request) -> httpx.Response: client._client = httpx.AsyncClient(transport=httpx.MockTransport(_capture)) - await client.search("") + await client.search(queries) - assert captured["body"] == {"queries": [""], "query": ""} + assert captured["body"] == expected_body class TestSQLBreadcrumb: From df040faa82a2db20809d9ccdabfcd6629651f9be Mon Sep 17 00:00:00 2001 From: Wonjae Lee Date: Wed, 7 Oct 2026 08:59:47 -0500 Subject: [PATCH 3/3] BDX-1369: Send only the queries field in catalog search requests Co-Authored-By: Claude Opus 5.5 --- src/drs/client.py | 4 +--- tests/test_client.py | 10 ++++------ 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/drs/client.py b/src/drs/client.py index 5fca33c..623d665 100644 --- a/src/drs/client.py +++ b/src/drs/client.py @@ -276,9 +276,7 @@ async def search( max_results: int | None = None, next_page_token: str | None = None, ) -> dict: - # Newer Dremio reads `queries`; older versions read only `query`. Both ignore unknown fields. - legacy_query = next((q for q in queries if q.strip()), "") - body: dict[str, Any] = {"queries": queries, "query": legacy_query} + body: dict[str, Any] = {"queries": queries} if filter_: body["filter"] = filter_ if max_results is not None: diff --git a/tests/test_client.py b/tests/test_client.py index d140dd7..3f221de 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -120,7 +120,6 @@ async def _capture(request: httpx.Request) -> httpx.Response: assert captured["body"] == { "queries": ["revenue"], - "query": "revenue", "filter": 'category in ["JOB"]', "maxResults": 20, } @@ -141,17 +140,16 @@ async def _capture(request: httpx.Request) -> httpx.Response: assert captured["body"] == { "queries": ["revenue"], - "query": "revenue", "pageToken": "token-123", } @pytest.mark.parametrize( ("queries", "expected_body"), [ - (["revenue"], {"queries": ["revenue"], "query": "revenue"}), - (["revenue", "sales"], {"queries": ["revenue", "sales"], "query": "revenue"}), - ([" ", "sales"], {"queries": [" ", "sales"], "query": "sales"}), - ([""], {"queries": [""], "query": ""}), + (["revenue"], {"queries": ["revenue"]}), + (["revenue", "sales"], {"queries": ["revenue", "sales"]}), + ([" ", "sales"], {"queries": [" ", "sales"]}), + ([""], {"queries": [""]}), ], ) @pytest.mark.asyncio