Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backend/app/api/v1/query_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,12 @@ async def _detail(db: AsyncSession, row: QuerySet) -> QuerySetDetail:
)


def _summary(row: QuerySet) -> QuerySetSummary:
def _summary(row: QuerySet, query_count: int) -> QuerySetSummary:
return QuerySetSummary(
id=row.id,
name=row.name,
cluster_id=row.cluster_id,
query_count=query_count,
created_at=row.created_at,
)

Expand Down Expand Up @@ -200,8 +201,11 @@ async def list_query_sets(
cursor_value = getattr(last, parsed_sort.col_name)
next_cursor = _sort_encode_cursor(cursor_value, last.id)
has_more = True
# One batched GROUP BY aggregate for the whole page — no per-row
# count (see QuerySetSummary docstring + repo.count_queries_for_sets).
counts = await repo.count_queries_for_sets(db, [r.id for r in rows])
return QuerySetListResponse(
data=[_summary(r) for r in rows],
data=[_summary(r, counts.get(r.id, 0)) for r in rows],
next_cursor=next_cursor,
has_more=has_more,
)
Expand Down
3 changes: 3 additions & 0 deletions backend/app/api/v1/query_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ def _summary(row: QueryTemplate) -> QueryTemplateSummary:
name=row.name,
engine_type=row.engine_type,
version=row.version,
# declared_params is a JSONB column already loaded on the row, so
# len() is free — no extra query, no N+1 (see QueryTemplateSummary).
param_count=len(row.declared_params),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If row.declared_params is None (which can happen if the JSONB column contains a NULL value in legacy database rows), calling len() directly will raise a TypeError: 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.

Suggested change
param_count=len(row.declared_params),
param_count=len(row.declared_params or {}),

created_at=row.created_at,
)

Expand Down
23 changes: 21 additions & 2 deletions backend/app/api/v1/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,12 +467,21 @@ class QueryTemplateDetail(BaseModel):


class QueryTemplateSummary(BaseModel):
"""List-view shape; drops ``body`` + ``declared_params`` for brevity."""
"""List-view shape; drops ``body`` + the full ``declared_params`` dict.

Surfaces ``param_count`` (= ``len(declared_params)``) so the
templates list can show each template's tuning surface at a glance.
``param_count`` is free to compute — ``declared_params`` is a JSONB
column already loaded on the row (not a child relationship), so the
count is ``len(row.declared_params)`` with no extra query and no
N+1 risk. The full dict remains on ``QueryTemplateDetail``.
"""

id: str
name: str
engine_type: EngineTypeWire
version: int
param_count: int
created_at: datetime


Expand Down Expand Up @@ -513,11 +522,21 @@ class QuerySetDetail(BaseModel):


class QuerySetSummary(BaseModel):
"""List-view shape; omits ``query_count`` to avoid N+1 counts at list time."""
"""List-view shape.

``query_count`` is the number of queries in the set. It is resolved
via a single batched ``GROUP BY query_set_id`` aggregate per page
(``repo.count_queries_for_sets``), NOT a per-row count — so the
list endpoint stays at a fixed 2 queries (the page + the count
aggregate) regardless of page size. This is the same no-N+1 pattern
``feat_studies_convergence_visibility`` (PR #421) used for the
studies-list ``trial_count`` field.
"""

id: str
name: str
cluster_id: str
query_count: int
created_at: datetime


Expand Down
2 changes: 2 additions & 0 deletions backend/app/db/repo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
update_query,
)
from backend.app.db.repo.query_set import (
count_queries_for_sets,
count_queries_in_set,
count_query_sets,
create_query_set,
Expand Down Expand Up @@ -183,6 +184,7 @@
"TrialsSummary",
"aggregate_trials_summary",
"bulk_create_queries",
"count_queries_for_sets",
"count_queries_in_set",
"count_query_sets",
"count_query_templates",
Expand Down
34 changes: 34 additions & 0 deletions backend/app/db/repo/query_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,40 @@ async def count_queries_in_set(db: AsyncSession, query_set_id: str) -> int:
return int((await db.execute(stmt)).scalar_one())


async def count_queries_for_sets(db: AsyncSession, query_set_ids: Sequence[str]) -> dict[str, int]:
"""Batched query counts for a page of query sets.

One ``GROUP BY query_set_id`` aggregate returning the COUNT(*) of
queries per set. Powers the query-sets-list ``query_count`` field
without a per-row count (the no-N+1 pattern mirroring
``repo.count_trials_for_studies`` from
``feat_studies_convergence_visibility``).

Sets whose id is in the input but have zero queries are returned
with ``0`` (backfilled) so callers can index by id without a
``KeyError``. Empty input returns an empty dict (no query issued).
"""
if not query_set_ids:
return {}
# Label the aggregate ``query_count`` (NOT ``count``): SQLAlchemy
# ``Row`` objects are tuple-like and expose a built-in ``.count()``
# method, so ``row.count`` would resolve to that bound method, not
# the labeled column. ``query_count`` has no such collision.
stmt = (
select(
Query.query_set_id.label("query_set_id"),
func.count(Query.id).label("query_count"),
)
.where(Query.query_set_id.in_(list(query_set_ids)))
.group_by(Query.query_set_id)
)
rows = (await db.execute(stmt)).all()
result: dict[str, int] = {row.query_set_id: int(row.query_count) for row in rows}
for qsid in query_set_ids:
result.setdefault(qsid, 0)
return result


# ---------------------------------------------------------------------------
# chore_e2e_test_rows_isolation Story 1.1 — hard-delete for test-only cleanup
# ---------------------------------------------------------------------------
Expand Down
71 changes: 71 additions & 0 deletions backend/tests/contract/test_list_count_fields_contract.py
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"
168 changes: 168 additions & 0 deletions backend/tests/integration/test_list_count_fields.py
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
2 changes: 1 addition & 1 deletion ui/openapi.json

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 ui/public/guides/03_create_query_template/walkthrough.webm
Binary file not shown.
Binary file modified ui/public/guides/04_create_query_set/01-query-sets-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 ui/public/guides/04_create_query_set/walkthrough.webm
Binary file not shown.
Loading
Loading