Skip to content

Commit 2f129c1

Browse files
author
ci bot
committed
Merge branch 'feat/TG-1053-mcp-entity-read' into 'enterprise'
feat(mcp): add entity read tools for projects, connections, table groups, test suites (TG-1053) See merge request dkinternal/testgen/dataops-testgen!538
2 parents 43ec6f0 + 13eff2b commit 2f129c1

12 files changed

Lines changed: 1219 additions & 13 deletions

File tree

testgen/common/models/connection.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ class ConnectionMinimal(EntityMinimal):
4343
connection_name: str
4444

4545

46+
@dataclass
47+
class ConnectionListItem(EntityMinimal):
48+
connection_id: int
49+
connection_name: str
50+
project_code: str
51+
sql_flavor_code: SQLFlavorCode
52+
project_host: str | None
53+
project_db: str | None
54+
table_group_count: int
55+
56+
4657
class Connection(Entity):
4758
__tablename__ = "connections"
4859

@@ -93,6 +104,35 @@ def select_minimal_where(
93104
results = cls._select_columns_where(cls._minimal_columns, *clauses, order_by=order_by)
94105
return [ConnectionMinimal(**row) for row in results]
95106

107+
@classmethod
108+
def list_for_project(
109+
cls, project_code: str, *clauses, page: int = 1, limit: int = 20
110+
) -> tuple[list[ConnectionListItem], int]:
111+
"""Paginated listing of connections in a project, with their table-group counts."""
112+
tg_count_subq = (
113+
select(
114+
TableGroup.connection_id.label("connection_id"),
115+
func.count().label("table_group_count"),
116+
)
117+
.group_by(TableGroup.connection_id)
118+
.subquery()
119+
)
120+
query = (
121+
select(
122+
cls.connection_id,
123+
cls.connection_name,
124+
cls.project_code,
125+
cls.sql_flavor_code,
126+
cls.project_host,
127+
cls.project_db,
128+
func.coalesce(tg_count_subq.c.table_group_count, 0).label("table_group_count"),
129+
)
130+
.outerjoin(tg_count_subq, tg_count_subq.c.connection_id == cls.connection_id)
131+
.where(cls.project_code == project_code, *clauses)
132+
.order_by(*cls._default_order_by)
133+
)
134+
return cls._paginate(query, page=page, limit=limit, data_class=ConnectionListItem)
135+
96136
@classmethod
97137
def is_in_use(cls, ids: list[str]) -> bool:
98138
table_groups = TableGroup.select_minimal_where(TableGroup.connection_id.in_(ids))

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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ class TableGroupStats(EntityMinimal):
4747
data_point_ct: int
4848

4949

50+
@dataclass
51+
class TableGroupListItem(EntityMinimal):
52+
id: UUID
53+
table_groups_name: str
54+
table_group_schema: str
55+
project_code: str
56+
connection_name: str | None
57+
table_count: int
58+
column_count: int | None
59+
row_count: int | None
60+
last_profiled_date: datetime | None
61+
last_tested_date: datetime | None
62+
profiling_score: float | None
63+
testing_score: float | None
64+
quality_score: float | None
65+
66+
5067
@dataclass
5168
class TableGroupSummary(EntityMinimal):
5269
id: UUID
@@ -149,6 +166,20 @@ class TableGroup(Entity):
149166
dq_score_testing,
150167
)
151168

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+
152183
@classmethod
153184
def get_minimal(cls, id_: str | UUID) -> TableGroupMinimal | None:
154185
result = cls._get_columns(id_, cls._minimal_columns)
@@ -385,6 +416,103 @@ def select_summary(
385416
total = items[0].total_count if items else 0
386417
return items, total
387418

419+
@classmethod
420+
def list_for_project(
421+
cls, project_code: str, *, page: int = 1, limit: int = 20
422+
) -> tuple[list[TableGroupListItem], int]:
423+
"""Config-focused paginated listing for a project, with table count and activity timestamps."""
424+
return cls._list_with_activity(
425+
scope_sql="groups.project_code = :project_code",
426+
scope_params={"project_code": project_code},
427+
page=page,
428+
limit=limit,
429+
)
430+
431+
@classmethod
432+
def list_for_connection(
433+
cls, connection_id: int, *, page: int = 1, limit: int = 20
434+
) -> tuple[list[TableGroupListItem], int]:
435+
"""Config-focused paginated listing for a connection, with table count and activity timestamps."""
436+
return cls._list_with_activity(
437+
scope_sql="groups.connection_id = :connection_id",
438+
scope_params={"connection_id": connection_id},
439+
page=page,
440+
limit=limit,
441+
)
442+
443+
@classmethod
444+
def _list_with_activity(
445+
cls,
446+
*,
447+
scope_sql: str,
448+
scope_params: dict,
449+
page: int,
450+
limit: int,
451+
) -> 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+
461+
params: dict = {**scope_params, "limit": limit, "offset": (page - 1) * limit}
462+
rows_query = f"""
463+
WITH stats AS (
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
468+
FROM data_table_chars
469+
GROUP BY table_groups_id
470+
),
471+
latest_profile AS (
472+
SELECT pr.table_groups_id, MAX(je.started_at) AS started_at
473+
FROM profiling_runs pr
474+
LEFT JOIN job_executions je ON je.id = pr.job_execution_id
475+
GROUP BY pr.table_groups_id
476+
),
477+
latest_test AS (
478+
SELECT ts.table_groups_id, MAX(tr.test_starttime) AS test_starttime
479+
FROM test_runs tr
480+
JOIN test_suites ts ON ts.id = tr.test_suite_id
481+
WHERE ts.is_monitor IS NOT TRUE
482+
GROUP BY ts.table_groups_id
483+
)
484+
SELECT
485+
groups.id,
486+
groups.table_groups_name,
487+
groups.table_group_schema,
488+
groups.project_code,
489+
connections.connection_name,
490+
COALESCE(stats.table_count, 0) AS table_count,
491+
stats.column_count,
492+
stats.row_count,
493+
latest_profile.started_at AS last_profiled_date,
494+
latest_test.test_starttime AS last_tested_date,
495+
groups.dq_score_profiling AS profiling_score,
496+
groups.dq_score_testing AS testing_score,
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
503+
FROM table_groups AS groups
504+
LEFT JOIN connections ON connections.connection_id = groups.connection_id
505+
LEFT JOIN stats ON stats.table_groups_id = groups.id
506+
LEFT JOIN latest_profile ON latest_profile.table_groups_id = groups.id
507+
LEFT JOIN latest_test ON latest_test.table_groups_id = groups.id
508+
WHERE {scope_sql}
509+
ORDER BY LOWER(groups.table_groups_name)
510+
LIMIT :limit OFFSET :offset;
511+
"""
512+
rows = session.execute(text(rows_query), params).mappings().all()
513+
items = [TableGroupListItem(**row) for row in rows]
514+
return items, total
515+
388516
@classmethod
389517
def is_in_use(cls, ids: list[str]) -> bool:
390518
test_suites = TestSuite.select_minimal_where(TestSuite.table_groups_id.in_(ids))

testgen/common/models/test_suite.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ class TestSuiteMinimal(EntityMinimal):
2929
export_to_observability: str
3030

3131

32+
@dataclass(frozen=True)
33+
class TestDefinitionStats:
34+
"""Aggregate test-definition counts for a test suite."""
35+
36+
total: int
37+
locked: int
38+
counts_by_type: dict[str, int]
39+
40+
3241
@dataclass
3342
class TestSuiteSummary(EntityMinimal):
3443
id: UUID
@@ -213,6 +222,36 @@ def select_summary(cls, project_code: str, table_group_id: str | UUID | None = N
213222
results = db_session.execute(text(query), params).mappings().all()
214223
return [TestSuiteSummary(**row) for row in results]
215224

225+
@classmethod
226+
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.
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+
"""
233+
query = """
234+
SELECT
235+
COALESCE(tt.test_name_short, td.test_type) AS type_label,
236+
COUNT(*) AS total,
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;
243+
"""
244+
rows = (
245+
get_current_session()
246+
.execute(text(query), {"test_suite_id": str(test_suite_id)})
247+
.mappings()
248+
.all()
249+
)
250+
counts_by_type = {row["type_label"]: int(row["total"]) for row in rows}
251+
total = sum(counts_by_type.values())
252+
locked = sum(int(row["locked"]) for row in rows)
253+
return TestDefinitionStats(total=total, locked=locked, counts_by_type=counts_by_type)
254+
216255
@classmethod
217256
def is_in_use(cls, ids: list[str]) -> bool:
218257
query = """
@@ -248,4 +287,3 @@ def cascade_delete(cls, ids: list[str]) -> None:
248287
db_session = get_current_session()
249288
db_session.execute(text(query), {"test_suite_ids": tuple(ids)})
250289
cls.delete_where(cls.id.in_(ids))
251-

testgen/mcp/server.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,20 @@ def build_mcp_server(
140140
profiling_overview,
141141
table_health,
142142
)
143-
from testgen.mcp.tools.connections import test_connection
143+
from testgen.mcp.tools.connections import (
144+
get_connection,
145+
list_connections,
146+
test_connection,
147+
)
144148
from testgen.mcp.tools.data_catalog import update_catalog_metadata
145-
from testgen.mcp.tools.discovery import get_data_inventory, list_projects, list_tables, list_test_suites
149+
from testgen.mcp.tools.discovery import (
150+
get_data_inventory,
151+
get_project,
152+
get_test_suite,
153+
list_projects,
154+
list_tables,
155+
list_test_suites,
156+
)
146157
from testgen.mcp.tools.execution import (
147158
cancel_profiling_run,
148159
cancel_test_run,
@@ -208,6 +219,8 @@ def build_mcp_server(
208219
from testgen.mcp.tools.source_data import get_source_data, get_source_data_query, get_table_sample
209220
from testgen.mcp.tools.table_groups import (
210221
create_table_group,
222+
get_table_group,
223+
list_table_groups,
211224
preview_table_group,
212225
update_table_group,
213226
)
@@ -263,8 +276,10 @@ def safe_prompt(fn):
263276
# Tools
264277
safe_tool(get_data_inventory)
265278
safe_tool(list_projects)
279+
safe_tool(get_project)
266280
safe_tool(list_tables)
267281
safe_tool(list_test_suites)
282+
safe_tool(get_test_suite)
268283
safe_tool(list_test_runs)
269284
safe_tool(get_test_run)
270285
safe_tool(list_test_results)
@@ -329,7 +344,11 @@ def safe_prompt(fn):
329344
safe_tool(create_notification)
330345
safe_tool(update_notification)
331346
safe_tool(delete_notification)
347+
safe_tool(list_connections)
348+
safe_tool(get_connection)
332349
safe_tool(test_connection)
350+
safe_tool(list_table_groups)
351+
safe_tool(get_table_group)
333352
safe_tool(create_table_group)
334353
safe_tool(update_table_group)
335354
safe_tool(preview_table_group)

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
#

0 commit comments

Comments
 (0)