Skip to content

Commit 4ba412a

Browse files
author
ci bot
committed
Merge branch 'mcp-table-groups' into 'enterprise'
feat(mcp): add tools to manage table groups See merge request dkinternal/testgen/dataops-testgen!530
2 parents bc6b46e + 0e0c35e commit 4ba412a

7 files changed

Lines changed: 2025 additions & 146 deletions

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
"""Shared table-group helpers — common-layer logic shared by MCP, the UI, and
2+
any future caller. Two pieces live here, both flavor-aware:
3+
4+
* ``validate_table_group_fields`` — required-field + format checks. Callers
5+
surface the returned bullets verbatim.
6+
* ``preview_table_group`` — schema-introspection preview. Returns
7+
``(preview, data_chars, sql_generator)``; the ``save_data_chars`` callback
8+
is built by callers via ``make_save_data_chars``.
9+
10+
None of these belong on the ``TableGroup`` model: preview opens an external
11+
DB connection (I/O against an arbitrary target system), and the field-required
12+
rules are form-shaped per-flavor logic, not model invariants.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from collections.abc import Callable
18+
from datetime import UTC, datetime
19+
from typing import TypedDict
20+
from uuid import UUID
21+
22+
from testgen.commands.queries.refresh_data_chars_query import RefreshDataCharsSQL
23+
from testgen.commands.run_refresh_data_chars import write_data_chars
24+
from testgen.common.database.column_chars import ColumnChars
25+
from testgen.common.database.flavor.flavor_service import resolve_connection_params
26+
from testgen.common.models.connection import Connection
27+
from testgen.common.models.table_group import TableGroup
28+
from testgen.ui.services.database_service import fetch_from_target_db
29+
30+
# ---------------------------------------------------------------------------
31+
# Validation
32+
# ---------------------------------------------------------------------------
33+
34+
_NAME_MIN = 3
35+
_NAME_MAX = 40
36+
_SAMPLE_PCT_MIN = 1
37+
_SAMPLE_PCT_MAX = 100
38+
39+
40+
def _missing(value: object) -> bool:
41+
return value is None or (isinstance(value, str) and not value.strip())
42+
43+
44+
def _coerce_int(value: object) -> int | None:
45+
"""Return ``int(value)`` for ints / numeric strings, ``None`` otherwise.
46+
47+
The model stores ``profiling_delay_days`` and ``profile_sample_percent`` as
48+
``String`` columns; callers may pass either ints or numeric strings. Return
49+
``None`` to signal "not parseable" so the validator can emit the right error.
50+
"""
51+
if isinstance(value, bool):
52+
return None
53+
if isinstance(value, int):
54+
return value
55+
if isinstance(value, str):
56+
try:
57+
return int(value.strip())
58+
except (ValueError, AttributeError):
59+
return None
60+
return None
61+
62+
63+
def validate_table_group_fields(table_group: TableGroup) -> list[str]:
64+
"""Return every validation error (empty list = valid).
65+
66+
Mirrors the per-field form validators on the UI ``Add Table Group`` wizard.
67+
The MCP tools call this and raise an ``MCPUserError`` containing the bullets
68+
— they never duplicate a rule themselves.
69+
"""
70+
errors: list[str] = []
71+
72+
name = table_group.table_groups_name
73+
if _missing(name):
74+
errors.append("`table_group_name` is required.")
75+
elif not (_NAME_MIN <= len(name.strip()) <= _NAME_MAX):
76+
errors.append(f"`table_group_name` must be between {_NAME_MIN} and {_NAME_MAX} characters.")
77+
78+
if _missing(table_group.table_group_schema):
79+
errors.append("`schema` is required.")
80+
81+
delay = _coerce_int(table_group.profiling_delay_days)
82+
if delay is None or delay < 0:
83+
errors.append("`profiling_delay_days` must be a non-negative integer.")
84+
85+
pct = _coerce_int(table_group.profile_sample_percent)
86+
if pct is None or not (_SAMPLE_PCT_MIN <= pct <= _SAMPLE_PCT_MAX):
87+
errors.append(f"`profile_sample_percent` must be between {_SAMPLE_PCT_MIN} and {_SAMPLE_PCT_MAX}.")
88+
89+
min_count = table_group.profile_sample_min_count
90+
if not isinstance(min_count, int) or isinstance(min_count, bool) or min_count < 0:
91+
errors.append("`profile_sample_min_count` must be a non-negative integer.")
92+
93+
return errors
94+
95+
96+
# ---------------------------------------------------------------------------
97+
# Preview
98+
# ---------------------------------------------------------------------------
99+
100+
101+
class StatsPreview(TypedDict, total=False):
102+
id: UUID | None
103+
table_groups_name: str
104+
table_group_schema: str
105+
table_ct: int | None
106+
column_ct: int | None
107+
approx_record_ct: int | None
108+
approx_data_point_ct: int | None
109+
110+
111+
class TablePreview(TypedDict):
112+
column_ct: int
113+
approx_record_ct: int | None
114+
approx_data_point_ct: int | None
115+
can_access: bool | None
116+
117+
118+
class TableGroupPreview(TypedDict):
119+
stats: StatsPreview
120+
tables: dict[str, TablePreview]
121+
success: bool
122+
message: str | None
123+
124+
125+
_NO_CONNECTION_MESSAGE = "No connection selected. Please select a connection to preview the Table Group."
126+
_NO_TABLES_MESSAGE = (
127+
"No tables found matching the criteria. Please check the Table Group configuration"
128+
" or the database permissions."
129+
)
130+
_INACCESSIBLE_MESSAGE = "Some tables were not accessible. Please the check the database permissions."
131+
132+
133+
def preview_table_group(
134+
table_group: TableGroup,
135+
*,
136+
connection: Connection | None = None,
137+
verify_access: bool = True,
138+
) -> tuple[TableGroupPreview, list[ColumnChars] | None, RefreshDataCharsSQL | None]:
139+
"""Probe the connected target DB for tables matching ``table_group``'s filters.
140+
141+
Returns ``(preview, data_chars, sql_generator)`` — three picklable values.
142+
On the error path (no connection, DDF failure, or empty schema) the second
143+
and third tuple elements are ``None``.
144+
145+
Use ``make_save_data_chars(data_chars, sql_generator)`` in callers that
146+
need to record the introspected metadata in ``data_chars``.
147+
"""
148+
preview: TableGroupPreview = {
149+
"stats": {
150+
"id": table_group.id,
151+
"table_groups_name": table_group.table_groups_name,
152+
"table_group_schema": table_group.table_group_schema,
153+
},
154+
"tables": {},
155+
"success": True,
156+
"message": None,
157+
}
158+
159+
if not (connection or table_group.connection_id):
160+
preview["success"] = False
161+
preview["message"] = _NO_CONNECTION_MESSAGE
162+
return preview, None, None
163+
164+
data_chars: list[ColumnChars] | None = None
165+
sql_generator: RefreshDataCharsSQL | None = None
166+
try:
167+
if connection is None:
168+
connection = Connection.get(table_group.connection_id)
169+
preview, data_chars, sql_generator = _build_preview(table_group, connection)
170+
171+
if verify_access and preview["success"]:
172+
for table_name in list(preview["tables"]):
173+
try:
174+
results = fetch_from_target_db(connection, *sql_generator.verify_access(table_name))
175+
except Exception:
176+
preview["tables"][table_name]["can_access"] = False
177+
else:
178+
preview["tables"][table_name]["can_access"] = bool(results) and len(results) > 0
179+
if not all(t["can_access"] for t in preview["tables"].values()):
180+
preview["message"] = _INACCESSIBLE_MESSAGE
181+
except Exception as error:
182+
preview["success"] = False
183+
preview["message"] = error.args[0] if error.args else str(error)
184+
data_chars = None
185+
sql_generator = None
186+
187+
return preview, data_chars, sql_generator
188+
189+
190+
def make_save_data_chars(
191+
data_chars: list[ColumnChars],
192+
sql_generator: RefreshDataCharsSQL,
193+
) -> Callable[[UUID], None]:
194+
"""Build the ``save_data_chars(table_group_id)`` callback for the caller.
195+
196+
Kept out of ``preview_table_group`` so the service's return value stays
197+
picklable; local closures don't pickle.
198+
"""
199+
def save(table_group_id: UUID) -> None:
200+
# Unsaved table groups won't have an ID yet; sync it before writing.
201+
sql_generator.table_group.id = table_group_id
202+
write_data_chars(data_chars, sql_generator, datetime.now(UTC))
203+
return save
204+
205+
206+
def _build_preview(
207+
table_group: TableGroup,
208+
connection: Connection,
209+
) -> tuple[TableGroupPreview, list[ColumnChars], RefreshDataCharsSQL]:
210+
sql_generator = RefreshDataCharsSQL(connection, table_group)
211+
if sql_generator.flavor_service.metadata_via_api:
212+
params = resolve_connection_params(connection.__dict__)
213+
api_columns = sql_generator.flavor_service.get_schema_columns(params, table_group.table_group_schema) or []
214+
data_chars = sql_generator.filter_schema_columns(api_columns)
215+
else:
216+
rows = fetch_from_target_db(connection, *sql_generator.get_schema_ddf())
217+
data_chars = [ColumnChars(**column) for column in rows]
218+
219+
preview: TableGroupPreview = {
220+
"stats": {
221+
"id": table_group.id,
222+
"table_groups_name": table_group.table_groups_name,
223+
"table_group_schema": table_group.table_group_schema,
224+
"table_ct": 0,
225+
"column_ct": 0,
226+
"approx_record_ct": None,
227+
"approx_data_point_ct": None,
228+
},
229+
"tables": {},
230+
"success": True,
231+
"message": None,
232+
}
233+
stats = preview["stats"]
234+
tables = preview["tables"]
235+
236+
for column in data_chars:
237+
if not tables.get(column.table_name):
238+
tables[column.table_name] = {
239+
"column_ct": 0,
240+
"approx_record_ct": column.approx_record_ct,
241+
"approx_data_point_ct": None,
242+
"can_access": None,
243+
}
244+
stats["table_ct"] += 1
245+
if column.approx_record_ct is not None:
246+
stats["approx_record_ct"] = (stats["approx_record_ct"] or 0) + column.approx_record_ct
247+
248+
stats["column_ct"] += 1
249+
tables[column.table_name]["column_ct"] += 1
250+
if column.approx_record_ct is not None:
251+
stats["approx_data_point_ct"] = (stats["approx_data_point_ct"] or 0) + column.approx_record_ct
252+
tables[column.table_name]["approx_data_point_ct"] = (
253+
tables[column.table_name]["approx_data_point_ct"] or 0
254+
) + column.approx_record_ct
255+
256+
if len(data_chars) <= 0:
257+
preview["success"] = False
258+
preview["message"] = _NO_TABLES_MESSAGE
259+
260+
return preview, data_chars, sql_generator

testgen/mcp/server.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,11 @@ def build_mcp_server(
201201
update_schedule,
202202
)
203203
from testgen.mcp.tools.source_data import get_source_data, get_source_data_query
204+
from testgen.mcp.tools.table_groups import (
205+
create_table_group,
206+
preview_table_group,
207+
update_table_group,
208+
)
204209
from testgen.mcp.tools.test_definitions import (
205210
bulk_update_tests,
206211
create_test,
@@ -314,6 +319,9 @@ def safe_prompt(fn):
314319
safe_tool(update_notification)
315320
safe_tool(delete_notification)
316321
safe_tool(test_connection)
322+
safe_tool(create_table_group)
323+
safe_tool(update_table_group)
324+
safe_tool(preview_table_group)
317325

318326
# Resources
319327
safe_resource("testgen://test-types", test_types_resource)

testgen/mcp/tools/common.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,18 @@ def format_page_footer(total: int, page: int, limit: int) -> str:
514514
# Extract a new resolve_<entity> here when a second caller needs the same parse-uuid +
515515
# perm-scoped lookup + collapsed-error pattern.
516516

517+
def resolve_connection(connection_id: int) -> Connection:
518+
"""Resolve a connection ID, collapsing missing-or-inaccessible into one error path."""
519+
perms = get_project_permissions()
520+
conn = Connection.get(
521+
connection_id,
522+
Connection.project_code.in_(perms.allowed_codes),
523+
)
524+
if conn is None:
525+
raise MCPResourceNotAccessible("Connection", str(connection_id))
526+
return conn
527+
528+
517529
def resolve_table_group(table_group_id: str) -> TableGroup:
518530
"""Resolve a TG ID, collapsing missing-or-inaccessible into one error path."""
519531
tg_uuid = parse_uuid(table_group_id, "table_group_id")
@@ -739,18 +751,6 @@ def format_notification_trigger(event: NotificationEvent | str, settings: dict |
739751
return None
740752

741753

742-
def resolve_connection(connection_id: int) -> Connection:
743-
"""Resolve a connection ID, collapsing missing-or-inaccessible into one error path."""
744-
perms = get_project_permissions()
745-
conn = Connection.get(
746-
connection_id,
747-
Connection.project_code.in_(perms.allowed_codes),
748-
)
749-
if conn is None:
750-
raise MCPResourceNotAccessible("Connection", str(connection_id))
751-
return conn
752-
753-
754754
# Flavor display labels are the single source of truth in ``common/flavors.py``
755755
# (shared with the UI page). These maps just re-shape them for the MCP layer.
756756
SQL_FLAVOR_CODE_TO_LABEL: dict[str, SqlFlavorLabel] = dict(FLAVOR_CODE_TO_LABEL)

0 commit comments

Comments
 (0)