Skip to content

Commit 4984a74

Browse files
author
testgen-ci-bot
committed
Merge remote-tracking branch 'origin/enterprise' into fix/mssql-schema-case-sensitive
2 parents 832f7b5 + 625bc07 commit 4984a74

13 files changed

Lines changed: 896 additions & 108 deletions

testgen/common/data_catalog_service.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
"""Shared data catalog convenience service.
1+
"""Shared Data Catalog service.
22
3-
Generates CREATE TABLE scripts from profiled column metadata and fetches
4-
sample rows from source tables. Used by both the Streamlit UI and MCP tools.
3+
Generates CREATE TABLE scripts from profiled column metadata, fetches sample rows
4+
from source tables, and reads/writes table/column catalog metadata. Used by both
5+
the Streamlit UI and the MCP tools so they share one code path.
56
"""
67
import logging
8+
from collections.abc import Mapping
79
from dataclasses import dataclass
8-
from typing import Literal
10+
from typing import Any, Literal
911
from uuid import UUID
1012

1113
import pandas as pd
@@ -14,12 +16,32 @@
1416
from testgen.common.database.flavor.flavor_service import FlavorService
1517
from testgen.common.models.connection import Connection
1618
from testgen.common.models.data_column import CreateScriptColumn, DataColumnChars
19+
from testgen.common.models.data_table import DataTable
20+
from testgen.common.models.table_group import TableGroup
1721
from testgen.common.pii_masking import get_pii_columns, mask_source_data_pii
1822
from testgen.ui.services.database_service import fetch_from_target_db
1923
from testgen.utils import to_dataframe
2024

2125
LOG = logging.getLogger("testgen")
2226

27+
DESCRIPTION_MAX_LENGTH = 1000
28+
TAG_MAX_LENGTH = 40
29+
30+
TAG_FIELDS = [
31+
"data_source",
32+
"source_system",
33+
"source_process",
34+
"business_domain",
35+
"stakeholder_group",
36+
"transform_level",
37+
"aggregation_level",
38+
"data_product",
39+
]
40+
41+
# Metadata fields settable per target type, keyed by their data_*_chars column names.
42+
_TABLE_FIELDS = ("description", *TAG_FIELDS, "critical_data_element")
43+
_COLUMN_FIELDS = ("description", *TAG_FIELDS, "critical_data_element", "excluded_data_element", "pii_flag")
44+
2345

2446
@dataclass
2547
class TableSampleResult:
@@ -117,3 +139,52 @@ def fetch_table_sample(
117139
pii_columns = get_pii_columns(str(table_group_id), schema_name, table_name)
118140
pii_redacted = mask_source_data_pii(df, pii_columns)
119141
return TableSampleResult("OK", df=df, pii_redacted=pii_redacted)
142+
143+
144+
def validate_metadata_fields(fields: Mapping[str, Any]) -> list[str]:
145+
"""Validate free-text field lengths, returning one message per violation.
146+
147+
Only checks length: ``description`` and the tag fields. ``None`` values clear the
148+
field and are always valid; absent keys are skipped.
149+
"""
150+
errors: list[str] = []
151+
152+
description = fields.get("description")
153+
if description is not None and len(description) > DESCRIPTION_MAX_LENGTH:
154+
errors.append(f"description must be {DESCRIPTION_MAX_LENGTH} characters or fewer.")
155+
156+
for tag in TAG_FIELDS:
157+
value = fields.get(tag)
158+
if value is not None and len(value) > TAG_MAX_LENGTH:
159+
errors.append(f"{tag} must be {TAG_MAX_LENGTH} characters or fewer.")
160+
161+
return errors
162+
163+
164+
def apply_table_metadata(table: DataTable, fields: Mapping[str, Any]) -> None:
165+
"""Set table metadata attributes for every present key (None clears, value sets)."""
166+
for field in _TABLE_FIELDS:
167+
if field in fields:
168+
setattr(table, field, fields[field])
169+
170+
171+
def apply_column_metadata(column: DataColumnChars, fields: Mapping[str, Any]) -> None:
172+
"""Set column metadata attributes for every present key (None clears, value sets)."""
173+
for field in _COLUMN_FIELDS:
174+
if field in fields:
175+
setattr(column, field, fields[field])
176+
177+
178+
def disable_autoflags(table_group: TableGroup, *, wrote_cde: bool, wrote_pii: bool) -> list[str]:
179+
"""Turn off auto-detect flags so the next profiling pass preserves manual marks.
180+
181+
Returns the names of the flags that were turned off (skips flags already off).
182+
"""
183+
disabled: list[str] = []
184+
if wrote_cde and table_group.profile_flag_cdes:
185+
table_group.profile_flag_cdes = False
186+
disabled.append("profile_flag_cdes")
187+
if wrote_pii and table_group.profile_flag_pii:
188+
table_group.profile_flag_pii = False
189+
disabled.append("profile_flag_pii")
190+
return disabled

testgen/common/models/data_column.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,16 +260,23 @@ class DataColumnChars(Entity):
260260
critical_data_element: bool | None = Column(Boolean)
261261
excluded_data_element: bool | None = Column(Boolean, nullable=True)
262262
pii_flag: str | None = Column(String(50), nullable=True)
263+
description: str | None = Column(String(1000))
264+
data_source: str | None = Column(String(40))
265+
source_system: str | None = Column(String(40))
266+
source_process: str | None = Column(String(40))
267+
business_domain: str | None = Column(String(40))
268+
stakeholder_group: str | None = Column(String(40))
269+
transform_level: str | None = Column(String(40))
270+
aggregation_level: str | None = Column(String(40))
271+
data_product: str | None = Column(String(40))
263272
drop_date: datetime | None = Column(postgresql.TIMESTAMP)
264273
last_complete_profile_run_id: UUID | None = Column(postgresql.UUID(as_uuid=True))
265274
dq_score_profiling: float | None = Column(Float)
266275
dq_score_testing: float | None = Column(Float)
267276

268277
_default_order_by = (asc(ordinal_position), asc(column_name))
269278

270-
# Unmapped columns: description, data_source, source_system, source_process,
271-
# business_domain, stakeholder_group, transform_level, aggregation_level,
272-
# data_product, add_date, last_mod_date, test_ct, last_test_date,
279+
# Unmapped columns: add_date, last_mod_date, test_ct, last_test_date,
273280
# tests_last_run, tests_7_days_prior, tests_30_days_prior, fails_last_run,
274281
# fails_7_days_prior, fails_30_days_prior, warnings_last_run,
275282
# warnings_7_days_prior, warnings_30_days_prior, valid_profile_issue_ct,

testgen/common/models/data_table.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,25 @@ class DataTable(Entity):
6868
record_ct: int | None = Column(BigInteger)
6969
approx_record_ct: int | None = Column(BigInteger)
7070
critical_data_element: bool | None = Column(Boolean)
71+
description: str | None = Column(String(1000))
72+
data_source: str | None = Column(String(40))
73+
source_system: str | None = Column(String(40))
74+
source_process: str | None = Column(String(40))
75+
business_domain: str | None = Column(String(40))
76+
stakeholder_group: str | None = Column(String(40))
77+
transform_level: str | None = Column(String(40))
78+
aggregation_level: str | None = Column(String(40))
79+
data_product: str | None = Column(String(40))
7180
drop_date: datetime | None = Column(postgresql.TIMESTAMP)
7281
last_complete_profile_run_id: UUID | None = Column(postgresql.UUID(as_uuid=True))
7382
dq_score_profiling: float | None = Column(Float)
7483
dq_score_testing: float | None = Column(Float)
7584

76-
# Unmapped columns: functional_table_type, description, data_source,
77-
# source_system, source_process, business_domain, stakeholder_group,
78-
# transform_level, aggregation_level, data_product, add_date,
85+
# The inherited Entity default orders by the textual label "id", but this model's
86+
# primary key column is "table_id" — order by a real column instead.
87+
_default_order_by = (asc(table_name),)
88+
89+
# Unmapped columns: functional_table_type, add_date,
7990
# last_refresh_date, last_profile_record_ct
8091

8192
@classmethod

testgen/mcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def build_mcp_server(
141141
table_health,
142142
)
143143
from testgen.mcp.tools.connections import test_connection
144+
from testgen.mcp.tools.data_catalog import update_catalog_metadata
144145
from testgen.mcp.tools.discovery import get_data_inventory, list_projects, list_tables, list_test_suites
145146
from testgen.mcp.tools.execution import (
146147
cancel_profiling_run,
@@ -332,6 +333,7 @@ def safe_prompt(fn):
332333
safe_tool(create_table_group)
333334
safe_tool(update_table_group)
334335
safe_tool(preview_table_group)
336+
safe_tool(update_catalog_metadata)
335337

336338
# Resources
337339
safe_resource("testgen://test-types", test_types_resource)

testgen/mcp/tools/data_catalog.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
from collections.abc import Mapping
2+
3+
from testgen.common.data_catalog_service import (
4+
TAG_FIELDS,
5+
apply_column_metadata,
6+
apply_table_metadata,
7+
disable_autoflags,
8+
validate_metadata_fields,
9+
)
10+
from testgen.common.models import with_database_session
11+
from testgen.common.models.data_column import DataColumnChars
12+
from testgen.common.models.data_table import DataTable
13+
from testgen.mcp.exceptions import MCPUserError
14+
from testgen.mcp.permissions import get_project_permissions, mcp_permission
15+
from testgen.mcp.tools.common import DocGroup, resolve_table_group
16+
from testgen.mcp.tools.markdown import MdDoc
17+
18+
_DOC_GROUP = DocGroup.MANAGE
19+
20+
_BOOLEAN_ARGS = ("cde", "xde", "pii")
21+
22+
# MCP argument name -> data_*_chars column name.
23+
_ARG_TO_COLUMN = {"cde": "critical_data_element", "xde": "excluded_data_element", "pii": "pii_flag"}
24+
_COLUMN_TO_ARG = {v: k for k, v in _ARG_TO_COLUMN.items()}
25+
26+
27+
@with_database_session
28+
@mcp_permission("disposition")
29+
def update_catalog_metadata(updates: list[dict]) -> str:
30+
"""Apply metadata updates to tables and columns within table groups.
31+
32+
Each update targets one table, or one column within a table, and sets one or
33+
more metadata fields. Rows are processed independently: a failure on one row
34+
does not stop the others, and the response reports the outcome of every row.
35+
36+
Omit a field to leave it unchanged, pass null to clear it, or pass a value to set it.
37+
38+
Args:
39+
updates: List of per-row update specs. Each spec accepts:
40+
table_group_id: UUID of the table group, e.g. from `get_data_inventory`.
41+
table_name: Name of the target table.
42+
column_name: Name of the target column. Omit for a table-level update.
43+
description: Free-text description (max 1000 characters).
44+
cde: Whether this is a critical data element (true/false). Set at the
45+
table level to apply to all its columns unless a column overrides it.
46+
xde: Whether to exclude the column from future profiling and test
47+
generation (true/false). Columns only.
48+
pii: Whether the column contains personally identifiable information
49+
(true/false). Columns only.
50+
data_source, source_system, source_process, business_domain,
51+
stakeholder_group, transform_level, aggregation_level, data_product:
52+
Catalog tags (max 40 characters each).
53+
"""
54+
if not updates:
55+
raise MCPUserError("Provide at least one update.")
56+
57+
perms = get_project_permissions()
58+
tg_cache: dict[str, object] = {}
59+
flag_writes: dict = {} # tg.id -> {"tg": tg, "cde": bool, "pii": bool}
60+
inheritance_notices: list[str] = []
61+
exclusion_notices: list[str] = []
62+
rows: list[tuple[str, str, str]] = []
63+
64+
for spec in updates:
65+
target = _target_label(spec)
66+
try:
67+
outcome, detail = _apply_update(spec, perms, tg_cache, flag_writes, inheritance_notices, exclusion_notices)
68+
rows.append((target, outcome, detail))
69+
except MCPUserError as err:
70+
rows.append((target, "Failed", str(err)))
71+
72+
autoflag_notices: list[str] = []
73+
for entry in flag_writes.values():
74+
disabled = disable_autoflags(entry["tg"], wrote_cde=entry["cde"], wrote_pii=entry["pii"])
75+
for flag in disabled:
76+
autoflag_notices.append(
77+
f"Auto-disabled {flag} on table group `{entry['tg'].table_groups_name}` to preserve manual marks."
78+
)
79+
80+
return _render(rows, autoflag_notices + inheritance_notices + exclusion_notices)
81+
82+
83+
def _target_label(spec: Mapping) -> str:
84+
table = spec.get("table_name") or "?"
85+
column = spec.get("column_name")
86+
return f"{table}.{column}" if column else table
87+
88+
89+
def _apply_update(spec, perms, tg_cache, flag_writes, inheritance_notices, exclusion_notices) -> tuple[str, str]:
90+
if not isinstance(spec, Mapping):
91+
raise MCPUserError("Each update must be an object.")
92+
93+
table_group_id = spec.get("table_group_id")
94+
table_name = spec.get("table_name")
95+
if not table_group_id:
96+
raise MCPUserError("table_group_id is required.")
97+
if not table_name:
98+
raise MCPUserError("table_name is required.")
99+
column_name = spec.get("column_name")
100+
is_column = column_name is not None
101+
102+
for arg in _BOOLEAN_ARGS:
103+
if arg in spec and spec[arg] is not None and not isinstance(spec[arg], bool):
104+
raise MCPUserError(f"{arg} must be true or false.")
105+
106+
if not is_column:
107+
if "xde" in spec:
108+
raise MCPUserError("xde applies to columns only; omit it for table-level updates.")
109+
if "pii" in spec:
110+
raise MCPUserError("pii applies to columns only; omit it for table-level updates.")
111+
112+
tg = tg_cache.get(table_group_id)
113+
if tg is None:
114+
tg = resolve_table_group(table_group_id)
115+
tg_cache[table_group_id] = tg
116+
117+
if "pii" in spec and not perms.has_permission("view_pii", tg.project_code):
118+
raise MCPUserError(f"Setting pii requires permission to view PII on project `{tg.project_code}`.")
119+
120+
fields = _build_fields(spec)
121+
errors = validate_metadata_fields(fields)
122+
if errors:
123+
raise MCPUserError("; ".join(errors))
124+
125+
if not fields:
126+
return "Skipped", "no metadata fields provided"
127+
128+
if is_column:
129+
target = _resolve_column(tg.id, table_name, column_name)
130+
if target is None:
131+
raise MCPUserError(f"Column `{column_name}` not found in table `{table_name}`.")
132+
else:
133+
target = _resolve_table(tg.id, table_name)
134+
if target is None:
135+
raise MCPUserError(f"Table `{table_name}` not found in this table group.")
136+
137+
# Disable a table group's auto-detect flag only on a real change, matching the UI's confirm-to-disable
138+
# behavior. A no-op write (e.g. cde: false on an already-false column) must not silently turn it off.
139+
cde_changed = "cde" in spec and target.critical_data_element != spec["cde"]
140+
pii_changed = "pii" in spec and target.pii_flag != ("MANUAL" if spec["pii"] else None)
141+
142+
if is_column:
143+
apply_column_metadata(target, fields)
144+
else:
145+
apply_table_metadata(target, fields)
146+
147+
entry = flag_writes.setdefault(tg.id, {"tg": tg, "cde": False, "pii": False})
148+
if cde_changed:
149+
entry["cde"] = True
150+
if pii_changed:
151+
entry["pii"] = True
152+
153+
if not is_column and spec.get("cde") is True:
154+
inheritance_notices.append(
155+
f"Table-level CDE set; affects all columns of `{table_name}` unless explicitly overridden."
156+
)
157+
if is_column and spec.get("xde") is True:
158+
exclusion_notices.append(
159+
f"Column `{table_name}.{column_name}` excluded; next profiling run and test generation will skip it."
160+
)
161+
162+
return "Updated", _change_summary(fields)
163+
164+
165+
def _build_fields(spec: Mapping) -> dict:
166+
"""Translate a spec into a {data_*_chars column: value} dict (pii bool -> MANUAL/None)."""
167+
fields: dict = {}
168+
if "description" in spec:
169+
fields["description"] = spec["description"]
170+
for tag in TAG_FIELDS:
171+
if tag in spec:
172+
fields[tag] = spec[tag]
173+
if "cde" in spec:
174+
fields["critical_data_element"] = spec["cde"]
175+
if "xde" in spec:
176+
fields["excluded_data_element"] = spec["xde"]
177+
if "pii" in spec:
178+
fields["pii_flag"] = "MANUAL" if spec["pii"] else None
179+
return fields
180+
181+
182+
def _resolve_table(table_groups_id, table_name: str):
183+
matches = list(DataTable.select_where(
184+
DataTable.table_groups_id == table_groups_id,
185+
DataTable.table_name == table_name,
186+
))
187+
return matches[0] if matches else None
188+
189+
190+
def _resolve_column(table_groups_id, table_name: str, column_name: str):
191+
matches = list(DataColumnChars.select_where(
192+
DataColumnChars.table_groups_id == table_groups_id,
193+
DataColumnChars.table_name == table_name,
194+
DataColumnChars.column_name == column_name,
195+
))
196+
return matches[0] if matches else None
197+
198+
199+
def _change_summary(fields: dict) -> str:
200+
parts = []
201+
for column, value in fields.items():
202+
label = _COLUMN_TO_ARG.get(column, column)
203+
parts.append(f"{label} cleared" if value is None else f"{label} set")
204+
return ", ".join(parts)
205+
206+
207+
def _render(rows: list[tuple[str, str, str]], notices: list[str]) -> str:
208+
succeeded = sum(1 for _, outcome, _ in rows if outcome == "Updated")
209+
skipped = sum(1 for _, outcome, _ in rows if outcome == "Skipped")
210+
failed = sum(1 for _, outcome, _ in rows if outcome == "Failed")
211+
212+
doc = MdDoc()
213+
doc.heading(1, "Catalog metadata update")
214+
doc.field("Rows attempted", len(rows))
215+
doc.field("Updated", succeeded)
216+
doc.field("Skipped", skipped)
217+
doc.field("Failed", failed)
218+
doc.table(["Target", "Outcome", "Details"], [list(row) for row in rows], code=[0])
219+
for notice in notices:
220+
doc.text(notice)
221+
return doc.render()

0 commit comments

Comments
 (0)