-
Notifications
You must be signed in to change notification settings - Fork 2
feat(lists): add Queries + Parameters count columns to list tables #436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
1a81781
feat(lists): add query_count + param_count columns to list tables
SoundMindsAI 027ad78
docs(planned): capture chore_guide_regen_after_list_count_columns
SoundMindsAI 4bc7c3f
docs(guides): regenerate 03 + 04 list screenshots showing new columns
SoundMindsAI 292fe9c
docs: regen dashboards + roadmap after chore folder removal
SoundMindsAI 5f56260
docs(guides): promote fresh walkthrough videos for 03 + 04
SoundMindsAI File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| # SPDX-FileCopyrightText: 2026 soundminds.ai | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Contract assertions for the list-summary count fields (feat_list_count_columns). | ||
|
|
||
| Asserts the OpenAPI schema documents: | ||
|
|
||
| * ``QuerySetSummary.query_count`` as a required integer. | ||
| * ``QueryTemplateSummary.param_count`` as a required integer. | ||
|
|
||
| These guard the wire contract the frontend's generated ``types.ts`` | ||
| consumes — if a future edit drops either field from the summary model, | ||
| the freshness gate would catch the snapshot drift but this test pins the | ||
| *shape* (type + required-ness) explicitly. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import AsyncIterator | ||
|
|
||
| import httpx | ||
| import pytest | ||
| import pytest_asyncio | ||
| from asgi_lifespan import LifespanManager | ||
|
|
||
| from backend.tests.conftest import postgres_reachable | ||
|
|
||
| _skip_if_no_pg = pytest.mark.skipif( | ||
| not postgres_reachable(), | ||
| reason="Postgres not reachable — router resolves get_db at boot", | ||
| ) | ||
|
|
||
|
|
||
| @pytest_asyncio.fixture | ||
| async def async_client() -> AsyncIterator[httpx.AsyncClient]: | ||
| from backend.app.main import app | ||
|
|
||
| async with LifespanManager(app): | ||
| async with httpx.AsyncClient( | ||
| transport=httpx.ASGITransport(app=app), | ||
| base_url="http://test", | ||
| timeout=30.0, | ||
| ) as client: | ||
| yield client | ||
|
|
||
|
|
||
| @_skip_if_no_pg | ||
| async def test_query_set_summary_documents_query_count( | ||
| async_client: httpx.AsyncClient, | ||
| ) -> None: | ||
| resp = await async_client.get("/openapi.json") | ||
| assert resp.status_code == 200 | ||
| schema = resp.json()["components"]["schemas"]["QuerySetSummary"] | ||
| props = schema["properties"] | ||
| assert "query_count" in props, "QuerySetSummary missing query_count" | ||
| assert props["query_count"]["type"] == "integer" | ||
| assert "query_count" in schema["required"], "query_count must be required" | ||
|
|
||
|
|
||
| @_skip_if_no_pg | ||
| async def test_query_template_summary_documents_param_count( | ||
| async_client: httpx.AsyncClient, | ||
| ) -> None: | ||
| resp = await async_client.get("/openapi.json") | ||
| assert resp.status_code == 200 | ||
| schema = resp.json()["components"]["schemas"]["QueryTemplateSummary"] | ||
| props = schema["properties"] | ||
| assert "param_count" in props, "QueryTemplateSummary missing param_count" | ||
| assert props["param_count"]["type"] == "integer" | ||
| assert "param_count" in schema["required"], "param_count must be required" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| # SPDX-FileCopyrightText: 2026 soundminds.ai | ||
| # | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Integration tests for the list-summary count fields (feat_list_count_columns). | ||
|
|
||
| Two additions to the list endpoints: | ||
|
|
||
| * ``GET /api/v1/query-sets`` items carry ``query_count`` — the number of | ||
| queries in the set, resolved via one batched ``GROUP BY`` aggregate per | ||
| page (``repo.count_queries_for_sets``), NOT a per-row count. | ||
| * ``GET /api/v1/query-templates`` items carry ``param_count`` — | ||
| ``len(declared_params)``, free off the already-loaded JSONB column. | ||
|
|
||
| All assertions go through the real FastAPI request pipeline via | ||
| ``async_client`` so the Pydantic response model + serialization are | ||
| exercised end-to-end against a real Postgres. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import uuid | ||
| from typing import Any | ||
|
|
||
| import httpx | ||
| import pytest | ||
| import uuid_utils | ||
|
|
||
| from backend.app.db import repo | ||
| from backend.app.db.session import get_session_factory | ||
| from backend.tests.conftest import postgres_reachable | ||
|
|
||
| pytestmark = [ | ||
| pytest.mark.integration, | ||
| pytest.mark.skipif( | ||
| not postgres_reachable(), | ||
| reason="Postgres not reachable — see docs/03_runbooks/local-dev.md", | ||
| ), | ||
| ] | ||
|
|
||
|
|
||
| async def _seed_query_set(num_queries: int) -> str: | ||
| """Seed cluster → query_set → N queries; return the query_set id.""" | ||
| factory = get_session_factory() | ||
| async with factory() as db: | ||
| cluster = await repo.create_cluster( | ||
| db, | ||
| id=str(uuid.uuid4()), | ||
| name=f"lcc-c-{uuid.uuid4().hex[:8]}", | ||
| engine_type="elasticsearch", | ||
| environment="dev", | ||
| base_url="http://stub:9200", | ||
| auth_kind="es_basic", | ||
| credentials_ref="ref", | ||
| ) | ||
| qs = await repo.create_query_set( | ||
| db, | ||
| id=str(uuid_utils.uuid7()), | ||
| name=f"lcc-qs-{uuid.uuid4().hex[:8]}", | ||
| cluster_id=cluster.id, | ||
| ) | ||
| for i in range(num_queries): | ||
| await repo.create_query( | ||
| db, | ||
| id=str(uuid_utils.uuid7()), | ||
| query_set_id=qs.id, | ||
| query_text=f"q-{i}", | ||
| reference_answer=None, | ||
| query_metadata=None, | ||
| ) | ||
| await db.commit() | ||
| return str(qs.id) | ||
|
|
||
|
|
||
| async def _seed_template(declared_params: dict[str, str]) -> str: | ||
| """Seed a query_template with the given declared_params; return its id.""" | ||
| factory = get_session_factory() | ||
| async with factory() as db: | ||
| tmpl = await repo.create_query_template( | ||
| db, | ||
| id=str(uuid_utils.uuid7()), | ||
| name=f"lcc-tmpl-{uuid.uuid4().hex[:8]}", | ||
| engine_type="elasticsearch", | ||
| body='{"query": {"match_all": {}}}', | ||
| declared_params=declared_params, | ||
| version=1, | ||
| parent_id=None, | ||
| ) | ||
| await db.commit() | ||
| return str(tmpl.id) | ||
|
|
||
|
|
||
| def _find_item(items: list[dict[str, Any]], item_id: str) -> dict[str, Any]: | ||
| for it in items: | ||
| if it["id"] == item_id: | ||
| return it | ||
| raise AssertionError(f"id {item_id} not found in list response") | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # query-sets: query_count | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| async def test_query_sets_list_includes_query_count( | ||
| async_client: httpx.AsyncClient, | ||
| ) -> None: | ||
| """A set with 3 queries reports query_count == 3 in the list.""" | ||
| set_id = await _seed_query_set(num_queries=3) | ||
| resp = await async_client.get("/api/v1/query-sets", params={"limit": 200}) | ||
| assert resp.status_code == 200, resp.text | ||
| item = _find_item(resp.json()["data"], set_id) | ||
| assert item["query_count"] == 3 | ||
|
|
||
|
|
||
| async def test_query_sets_list_zero_queries_reports_zero( | ||
| async_client: httpx.AsyncClient, | ||
| ) -> None: | ||
| """A set with no queries reports query_count == 0 (backfilled, not missing).""" | ||
| set_id = await _seed_query_set(num_queries=0) | ||
| resp = await async_client.get("/api/v1/query-sets", params={"limit": 200}) | ||
| assert resp.status_code == 200, resp.text | ||
| item = _find_item(resp.json()["data"], set_id) | ||
| assert item["query_count"] == 0 | ||
|
|
||
|
|
||
| async def test_query_sets_list_counts_are_per_set( | ||
| async_client: httpx.AsyncClient, | ||
| ) -> None: | ||
| """Two sets with different cardinalities each get their own count. | ||
|
|
||
| Guards the batched ``GROUP BY`` mapping — a regression that collapsed | ||
| all sets to one count (or mis-keyed the dict) would fail here. | ||
| """ | ||
| five_id = await _seed_query_set(num_queries=5) | ||
| one_id = await _seed_query_set(num_queries=1) | ||
| resp = await async_client.get("/api/v1/query-sets", params={"limit": 200}) | ||
| assert resp.status_code == 200, resp.text | ||
| items = resp.json()["data"] | ||
| assert _find_item(items, five_id)["query_count"] == 5 | ||
| assert _find_item(items, one_id)["query_count"] == 1 | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # query-templates: param_count | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| async def test_templates_list_includes_param_count( | ||
| async_client: httpx.AsyncClient, | ||
| ) -> None: | ||
| """A template with 3 declared params reports param_count == 3.""" | ||
| tmpl_id = await _seed_template({"a": "term", "b": "term", "c": "term"}) | ||
| resp = await async_client.get("/api/v1/query-templates", params={"limit": 200}) | ||
| assert resp.status_code == 200, resp.text | ||
| item = _find_item(resp.json()["data"], tmpl_id) | ||
| assert item["param_count"] == 3 | ||
|
|
||
|
|
||
| async def test_templates_list_zero_params_reports_zero( | ||
| async_client: httpx.AsyncClient, | ||
| ) -> None: | ||
| """A template with no declared params reports param_count == 0.""" | ||
| tmpl_id = await _seed_template({}) | ||
| resp = await async_client.get("/api/v1/query-templates", params={"limit": 200}) | ||
| assert resp.status_code == 200, resp.text | ||
| item = _find_item(resp.json()["data"], tmpl_id) | ||
| assert item["param_count"] == 0 |
Large diffs are not rendered by default.
Oops, something went wrong.
Binary file modified
BIN
-12.8 KB
(87%)
ui/public/guides/03_create_query_template/01-templates-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+14.5 KB
(120%)
ui/public/guides/03_create_query_template/02-create-modal-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+14.7 KB
(120%)
ui/public/guides/03_create_query_template/03-create-modal-filled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-4.91 KB
(95%)
ui/public/guides/03_create_query_template/04-template-created.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
+1.32 KB
(100%)
ui/public/guides/03_create_query_template/05-template-detail-fork-button.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-28.6 KB
(75%)
ui/public/guides/04_create_query_set/02-create-modal-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-28.3 KB
(76%)
ui/public/guides/04_create_query_set/03-create-modal-filled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-6.39 KB
(91%)
ui/public/guides/04_create_query_set/04-query-set-detail-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified
BIN
-5.55 KB
(93%)
ui/public/guides/04_create_query_set/05-add-queries-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If
row.declared_paramsisNone(which can happen if the JSONB column contains aNULLvalue in legacy database rows), callinglen()directly will raise aTypeError: object of type 'NoneType' has no len(). To prevent potential 500 Internal Server Errors on the list endpoint, it is safer to use a fallback default dictionary.