Skip to content

Commit 13eff2b

Browse files
diogodkclaude
andcommitted
refactor(mcp): apply TG-1053 review feedback
Correctness: - Project.get_summary: exclude is_monitor suites from test_suite_count / test_definition_count / test_run_count subqueries (the rule applied per CLAUDE.md monitor-exclusion). - TableGroup._list_with_activity: separate COUNT(*) so total is correct on out-of-range pages (window function over an empty page returned 0). - TestSuite.test_definition_stats: render user-facing test_name_short labels via LEFT JOIN to test_types instead of leaking raw test_type codes. Output shape: - list_table_groups: expose Columns + Rows + all 3 quality scores (profiling, testing, overall), column header is "Quality Score" (overall). - get_table_group: drop dev-facing first sentence; field labels via the _DIFF_LABELS map for parity with create/update tools; Latest activity now includes the calculated overall Quality Score via a new TableGroup.quality_score property. - get_connection: rendering consolidated through a new _render_connection_body helper shared with _render_created_connection. - get_project: restructured into UI-mirroring sections (Configuration / Observability Integration / Data Retention) with UI-matching labels. Defense in depth: - get_test_suite / get_table_group route Connection / TableGroup lookups through resolve_connection / resolve_table_group instead of Model.get(). - format_flavor_label helper centralizes the SQL_FLAVOR_CODE_TO_LABEL + fallback pattern used across the read tools. Tests: - get_connection redaction test uses a real Connection with EncryptedBytea fields populated; previously vacuous (mocks bypassed the columns entirely). - get_test_suite monitor-rejection test now drives an actual is_monitor=True suite through resolve_test_suite's filter (was only exercising the missing- entity path). - Updated mocks and assertions for the new label/field set. Docs: - mcp-patterns.md followable-IDs table updated with the new producers / consumers introduced by this MR. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3216d15 commit 13eff2b

10 files changed

Lines changed: 282 additions & 170 deletions

File tree

testgen/common/models/project.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,23 @@ def get_summary(cls, project_code: str) -> ProjectSummary | None:
6666
WHERE table_groups.project_code = :project_code
6767
) AS profiling_run_count,
6868
(
69-
SELECT COUNT(*) FROM test_suites WHERE test_suites.project_code = :project_code
69+
SELECT COUNT(*) FROM test_suites
70+
WHERE test_suites.project_code = :project_code
71+
AND test_suites.is_monitor IS NOT TRUE
7072
) AS test_suite_count,
7173
(
7274
SELECT COUNT(*)
7375
FROM test_definitions
7476
LEFT JOIN test_suites ON test_definitions.test_suite_id = test_suites.id
7577
WHERE test_suites.project_code = :project_code
78+
AND test_suites.is_monitor IS NOT TRUE
7679
) AS test_definition_count,
7780
(
7881
SELECT COUNT(*)
7982
FROM test_runs
8083
LEFT JOIN test_suites ON test_runs.test_suite_id = test_suites.id
8184
WHERE test_suites.project_code = :project_code
85+
AND test_suites.is_monitor IS NOT TRUE
8286
) AS test_run_count,
8387
(
8488
SELECT COALESCE(observability_api_key, '') <> ''

testgen/common/models/table_group.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,13 @@ class TableGroupListItem(EntityMinimal):
5555
project_code: str
5656
connection_name: str | None
5757
table_count: int
58+
column_count: int | None
59+
row_count: int | None
5860
last_profiled_date: datetime | None
5961
last_tested_date: datetime | None
62+
profiling_score: float | None
6063
testing_score: float | None
64+
quality_score: float | None
6165

6266

6367
@dataclass
@@ -162,6 +166,20 @@ class TableGroup(Entity):
162166
dq_score_testing,
163167
)
164168

169+
@property
170+
def quality_score(self) -> float:
171+
"""Overall quality score per ``utils.score``: profiling * testing when both
172+
exist; the non-null one when only one ran; ``0.0`` when neither has run.
173+
174+
Mirrors what Project Dashboard, data_catalog, and inventory_service display.
175+
Render through ``utils.friendly_score`` to get the user-facing percentage form
176+
(``"95.0"`` rather than ``"0.95"``). The list-side equivalent is computed in
177+
SQL inside ``_list_with_activity`` — keep both in sync if the formula changes.
178+
"""
179+
from testgen.utils import score
180+
181+
return score(self.dq_score_profiling, self.dq_score_testing)
182+
165183
@classmethod
166184
def get_minimal(cls, id_: str | UUID) -> TableGroupMinimal | None:
167185
result = cls._get_columns(id_, cls._minimal_columns)
@@ -431,10 +449,22 @@ def _list_with_activity(
431449
page: int,
432450
limit: int,
433451
) -> tuple[list[TableGroupListItem], int]:
452+
session = get_current_session()
453+
454+
# Separate COUNT(*) query — keeps `total` correct on out-of-range pages where
455+
# the page rows are empty (a window function over zero rows would return 0).
456+
total_query = f"SELECT COUNT(*) FROM table_groups AS groups WHERE {scope_sql};"
457+
total = session.execute(text(total_query), scope_params).scalar() or 0
458+
if total == 0:
459+
return [], 0
460+
434461
params: dict = {**scope_params, "limit": limit, "offset": (page - 1) * limit}
435-
query = f"""
462+
rows_query = f"""
436463
WITH stats AS (
437-
SELECT table_groups_id, COUNT(*) AS table_count
464+
SELECT table_groups_id,
465+
COUNT(*) AS table_count,
466+
SUM(column_ct) AS column_count,
467+
SUM(COALESCE(record_ct, approx_record_ct)) AS row_count
438468
FROM data_table_chars
439469
GROUP BY table_groups_id
440470
),
@@ -458,10 +488,18 @@ def _list_with_activity(
458488
groups.project_code,
459489
connections.connection_name,
460490
COALESCE(stats.table_count, 0) AS table_count,
491+
stats.column_count,
492+
stats.row_count,
461493
latest_profile.started_at AS last_profiled_date,
462494
latest_test.test_starttime AS last_tested_date,
495+
groups.dq_score_profiling AS profiling_score,
463496
groups.dq_score_testing AS testing_score,
464-
COUNT(*) OVER() AS total_count
497+
-- Mirrors utils.score: product when both exist, else the non-null one, else NULL.
498+
CASE
499+
WHEN groups.dq_score_profiling IS NOT NULL AND groups.dq_score_testing IS NOT NULL
500+
THEN groups.dq_score_profiling * groups.dq_score_testing
501+
ELSE COALESCE(groups.dq_score_profiling, groups.dq_score_testing)
502+
END AS quality_score
465503
FROM table_groups AS groups
466504
LEFT JOIN connections ON connections.connection_id = groups.connection_id
467505
LEFT JOIN stats ON stats.table_groups_id = groups.id
@@ -471,14 +509,8 @@ def _list_with_activity(
471509
ORDER BY LOWER(groups.table_groups_name)
472510
LIMIT :limit OFFSET :offset;
473511
"""
474-
rows = get_current_session().execute(text(query), params).mappings().all()
475-
if not rows:
476-
return [], 0
477-
total = int(rows[0]["total_count"])
478-
items = [
479-
TableGroupListItem(**{k: v for k, v in row.items() if k != "total_count"})
480-
for row in rows
481-
]
512+
rows = session.execute(text(rows_query), params).mappings().all()
513+
items = [TableGroupListItem(**row) for row in rows]
482514
return items, total
483515

484516
@classmethod

testgen/common/models/test_suite.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -224,24 +224,30 @@ def select_summary(cls, project_code: str, table_group_id: str | UUID | None = N
224224

225225
@classmethod
226226
def test_definition_stats(cls, test_suite_id: str | UUID) -> "TestDefinitionStats":
227-
"""Aggregate test-definition counts for a suite: total, locked, and per-test-type."""
227+
"""Aggregate test-definition counts for a suite: total, locked, and per-test-type.
228+
229+
The per-type bucket uses the user-facing ``test_name_short`` label from
230+
``test_types``, falling back to the raw ``test_type`` code when the lookup
231+
row is missing (defensive — every shipping test type has a short name).
232+
"""
228233
query = """
229234
SELECT
230-
test_type,
235+
COALESCE(tt.test_name_short, td.test_type) AS type_label,
231236
COUNT(*) AS total,
232-
SUM(CASE WHEN COALESCE(lock_refresh, 'N') = 'Y' THEN 1 ELSE 0 END) AS locked
233-
FROM test_definitions
234-
WHERE test_suite_id = :test_suite_id
235-
GROUP BY test_type
236-
ORDER BY test_type;
237+
SUM(CASE WHEN COALESCE(td.lock_refresh, 'N') = 'Y' THEN 1 ELSE 0 END) AS locked
238+
FROM test_definitions td
239+
LEFT JOIN test_types tt ON tt.test_type = td.test_type
240+
WHERE td.test_suite_id = :test_suite_id
241+
GROUP BY type_label
242+
ORDER BY type_label;
237243
"""
238244
rows = (
239245
get_current_session()
240246
.execute(text(query), {"test_suite_id": str(test_suite_id)})
241247
.mappings()
242248
.all()
243249
)
244-
counts_by_type = {row["test_type"]: int(row["total"]) for row in rows}
250+
counts_by_type = {row["type_label"]: int(row["total"]) for row in rows}
245251
total = sum(counts_by_type.values())
246252
locked = sum(int(row["locked"]) for row in rows)
247253
return TestDefinitionStats(total=total, locked=locked, counts_by_type=counts_by_type)

testgen/mcp/tools/common.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,18 @@ def parse_sql_flavor(value: str) -> tuple[SqlFlavorLabel, str, str]:
874874
return label, code, SQL_FLAVOR_CODE_TO_FAMILY[code]
875875

876876

877+
def format_flavor_label(sql_flavor_code: str | None) -> str:
878+
"""Map a stored ``sql_flavor_code`` to its user-facing display label.
879+
880+
Returns the raw code as a fallback when the code is not in the registry — defensive
881+
against a never-shipping-but-still-stored value rather than letting an LLM see ``None``.
882+
"""
883+
if sql_flavor_code is None:
884+
return ""
885+
label = SQL_FLAVOR_CODE_TO_LABEL.get(sql_flavor_code)
886+
return label.value if label else sql_flavor_code
887+
888+
877889
# ===========================================================================
878890
# Connection-parameter contract (MCP input vocabulary)
879891
#

testgen/mcp/tools/connections.py

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
apply_connection_params,
2727
connection_display_fields,
2828
connection_field_labels,
29+
format_flavor_label,
2930
format_page_footer,
3031
format_page_info,
3132
infer_mode,
@@ -69,13 +70,11 @@ def list_connections(project_code: str, page: int = 1, limit: int = 20) -> str:
6970
doc.text(format_page_info(total, page, limit))
7071
table_rows: list[list[object]] = []
7172
for row in rows:
72-
label = SQL_FLAVOR_CODE_TO_LABEL.get(row.sql_flavor_code)
73-
database_type = label.value if label else row.sql_flavor_code
7473
table_rows.append(
7574
[
7675
row.connection_id,
7776
row.connection_name,
78-
database_type,
77+
format_flavor_label(row.sql_flavor_code),
7978
row.project_host,
8079
row.project_db,
8180
row.table_group_count,
@@ -100,38 +99,12 @@ def get_connection(connection_id: int) -> str:
10099
Use this before editing a connection or creating a table group on it.
101100
102101
Args:
103-
connection_id: Bigint connection ID returned by `list_connections` or shown on the connections page.
102+
connection_id: Bigint connection ID returned by `list_connections`.
104103
"""
105104
connection = resolve_connection(connection_id)
106-
107105
doc = MdDoc()
108106
doc.heading(1, f"Connection `{connection.connection_name}`")
109-
doc.field("ID", connection.connection_id, code=True)
110-
doc.field("Project", connection.project_code, code=True)
111-
label = SQL_FLAVOR_CODE_TO_LABEL.get(connection.sql_flavor_code)
112-
doc.field("Type", label.value if label else connection.sql_flavor_code)
113-
114-
if connection.project_host:
115-
doc.field("Host", connection.project_host, code=True)
116-
if connection.project_port:
117-
doc.field("Port", connection.project_port)
118-
if connection.project_db:
119-
doc.field("Database", connection.project_db, code=True)
120-
if connection.project_user:
121-
doc.field("User", connection.project_user, code=True)
122-
if connection.connect_by_url and connection.url:
123-
doc.field("URL", connection.url, code=True)
124-
if connection.warehouse:
125-
doc.field("Warehouse", connection.warehouse, code=True)
126-
if connection.http_path:
127-
doc.field("HTTP Path", connection.http_path, code=True)
128-
doc.field("Authentication", _authentication_label(connection))
129-
130-
if connection.max_threads is not None:
131-
doc.field("Max Threads", connection.max_threads)
132-
if connection.max_query_chars is not None:
133-
doc.field("Max Expression Length", connection.max_query_chars)
134-
107+
_render_connection_body(doc, connection)
135108
return doc.render()
136109

137110

@@ -236,12 +209,22 @@ def _raise_validation_error(errors: list[str], header: str) -> None:
236209
def _render_created_connection(connection: Connection) -> str:
237210
doc = MdDoc()
238211
doc.heading(1, f"Connection `{connection.connection_name}` created")
212+
_render_connection_body(doc, connection)
213+
return doc.render()
214+
215+
216+
def _render_connection_body(doc: MdDoc, connection: Connection) -> None:
217+
"""Render every non-secret connection field below the heading.
218+
219+
Shared by ``create_connection`` (the response after a successful create) and
220+
``get_connection`` (read tool) so the surfaced field set stays consistent.
221+
Encrypted columns are filtered out via ``ConnField.secret``.
222+
"""
239223
doc.field("ID", connection.connection_id, code=True)
240224
doc.field("Project", connection.project_code, code=True)
241-
label = SQL_FLAVOR_CODE_TO_LABEL.get(connection.sql_flavor_code)
242-
doc.field("Type", label.value if label else connection.sql_flavor_code)
225+
doc.field("Type", format_flavor_label(connection.sql_flavor_code))
243226

244-
# Render each populated, non-secret field under its flavor-specific label
227+
# Each populated, non-secret field under its flavor-specific label
245228
# (e.g. "Catalog" for Databricks, "Login URL" for Salesforce).
246229
for fld in connection_display_fields(connection):
247230
if fld.secret:
@@ -257,8 +240,6 @@ def _render_created_connection(connection: Connection) -> str:
257240
if connection.max_query_chars is not None:
258241
doc.field("Max Expression Length", connection.max_query_chars)
259242

260-
return doc.render()
261-
262243

263244
def _authentication_label(connection: Connection) -> str:
264245
"""The connection's auth method: the active connection mode for multi-mode

testgen/mcp/tools/discovery.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
from testgen.common.mixpanel_service import MixpanelService
22
from testgen.common.models import with_database_session
3-
from testgen.common.models.connection import Connection
43
from testgen.common.models.data_table import DataTable
54
from testgen.common.models.project import Project
6-
from testgen.common.models.table_group import TableGroup
75
from testgen.common.models.test_run import TestRun
86
from testgen.common.models.test_suite import TestSuite
97
from testgen.mcp.exceptions import MCPResourceNotAccessible
108
from testgen.mcp.permissions import get_project_permissions, mcp_permission
119
from testgen.mcp.tools.common import (
12-
SQL_FLAVOR_CODE_TO_LABEL,
1310
DocGroup,
11+
format_flavor_label,
12+
resolve_connection,
1413
resolve_table_group,
1514
resolve_test_suite,
1615
validate_limit,
@@ -93,13 +92,17 @@ def get_project(project_code: str) -> str:
9392
doc.field("Test runs", summary.test_run_count)
9493

9594
doc.heading(2, "Configuration")
96-
doc.field("Quality-score weighting", project.use_dq_score_weights)
97-
doc.field("Data retention", project.data_retention_enabled)
98-
if project.data_retention_enabled and project.data_retention_days is not None:
99-
doc.field("Retention period (days)", project.data_retention_days)
100-
doc.field("Observability export configured", summary.can_export_to_observability)
95+
doc.field("Weighted data quality scoring", project.use_dq_score_weights)
96+
97+
doc.heading(2, "Observability Integration")
98+
doc.field("Configured", summary.can_export_to_observability)
10199
if project.observability_api_url:
102-
doc.field("Observability API URL", project.observability_api_url, code=True)
100+
doc.field("API URL", project.observability_api_url, code=True)
101+
102+
doc.heading(2, "Data Retention")
103+
doc.field("Automatically delete old profiling and test history", project.data_retention_enabled)
104+
if project.data_retention_enabled and project.data_retention_days is not None:
105+
doc.field("Delete history older than (days)", project.data_retention_days)
103106

104107
return doc.render()
105108

@@ -159,18 +162,21 @@ def list_test_suites(project_code: str) -> str:
159162
@with_database_session
160163
@mcp_permission("view")
161164
def get_test_suite(test_suite_id: str) -> str:
162-
"""Get a test suite's configuration: default severity, connection, table group, and per-test-type counts.
165+
"""Get a test suite's configuration: connection, table group, default severity, and per-test-type counts.
163166
164167
Returns the test suite's identity and configuration along with a breakdown of how many test
165-
definitions it contains by type and how many are lock-refreshed (excluded from regeneration).
168+
definitions it contains by type and how many are locked (excluded from regeneration).
166169
Use this before changing a suite's tests to understand what will be affected.
167170
168171
Args:
169172
test_suite_id: The test suite UUID, e.g. from `list_test_suites`.
170173
"""
171174
suite = resolve_test_suite(test_suite_id)
172-
connection = Connection.get(suite.connection_id) if suite.connection_id else None
173-
table_group = TableGroup.get(suite.table_groups_id) if suite.table_groups_id else None
175+
# Defense in depth: resolve via perm-filtered helpers rather than `Model.get(...)`.
176+
# FK constraints guarantee same-project today; the resolvers are the established wrapper
177+
# for project-scoped lookups and keep us aligned if those guarantees ever change.
178+
connection = resolve_connection(suite.connection_id) if suite.connection_id else None
179+
table_group = resolve_table_group(str(suite.table_groups_id)) if suite.table_groups_id else None
174180
stats = TestSuite.test_definition_stats(suite.id)
175181

176182
doc = MdDoc()
@@ -179,11 +185,9 @@ def get_test_suite(test_suite_id: str) -> str:
179185
doc.field("Project", suite.project_code, code=True)
180186

181187
if connection is not None:
182-
label = SQL_FLAVOR_CODE_TO_LABEL.get(connection.sql_flavor_code)
183-
flavor_label = label.value if label else connection.sql_flavor_code
184188
doc.field(
185189
"Connection",
186-
f"{connection.connection_name} (`{connection.connection_id}`, {flavor_label})",
190+
f"{connection.connection_name} (`{connection.connection_id}`, {format_flavor_label(connection.sql_flavor_code)})",
187191
)
188192
if table_group is not None:
189193
doc.field(
@@ -201,8 +205,8 @@ def get_test_suite(test_suite_id: str) -> str:
201205

202206
if stats.counts_by_type:
203207
doc.heading(2, "Tests by type")
204-
rows = [[test_type, count] for test_type, count in stats.counts_by_type.items()]
205-
doc.table(["Test type", "Count"], rows, code=[0])
208+
rows = [[type_label, count] for type_label, count in stats.counts_by_type.items()]
209+
doc.table(["Test", "Count"], rows)
206210

207211
return doc.render()
208212

0 commit comments

Comments
 (0)