Skip to content

Commit 5a351fe

Browse files
m1n0claude
andcommitted
feat(cloud): emit dataset schema metadata when a schema check runs
Add a top-level metadata[] block to the sodaCoreInsertScanResults payload carrying each dataset's discovered columns ({columnName, sourceDataType}) whenever a schema check produced a non-empty column set, so Soda Cloud can refresh the dataset schema from a v4 contract or data standard. Matches the v3 discover payload shape: raw un-parameterized data type (SqlDataType.name), primary-key flag omitted. Emitted once per distinct dataset that ran a schema check, and omitted entirely when no schema check discovered columns so an empty schema never reaches the wire (the backend full-reconciles and would otherwise soft-delete every column). DTL-1807 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e056ec3 commit 5a351fe

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

soda-core/src/soda_core/common/soda_cloud.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,6 +1588,56 @@ def _build_token_usage_dicts(contract_verification_result: ContractVerificationR
15881588
return []
15891589

15901590

1591+
def _build_dataset_metadata_json_dicts(results: list[ContractVerificationResult]) -> list[dict]:
1592+
"""Top-level ``metadata`` array that refreshes each dataset's column schema
1593+
in Soda Cloud.
1594+
1595+
One entry per distinct dataset that ran a schema check which discovered a
1596+
non-empty column set. Reuses v3 discover's ``{columnName, sourceDataType}``
1597+
shape so the backend runs the same full column reconciliation (columns
1598+
absent from ``schema`` are soft-deleted) — so we send every discovered
1599+
column, in discovery order, and never an empty schema.
1600+
1601+
``datasetQualifiedName`` is the collection's ``soda_qualified_dataset_name``
1602+
(the raw ``yaml.dataset`` dqn), identical to the identifier the backend
1603+
builds from each check, so the block resolves to the same dataset the checks
1604+
registered under. ``sourceDataType`` is the raw, un-parameterized DB type
1605+
(``SqlDataType.name``, e.g. ``character varying``) — not the parameterized
1606+
``character varying(255)`` form — matching what v3 discover uploads.
1607+
``isPrimaryKey`` is deliberately omitted (the v4 schema check doesn't
1608+
discover PKs); omitting it preserves the backend's existing PK flags.
1609+
"""
1610+
from soda_core.contracts.impl.check_types.schema_check import SchemaCheckResult
1611+
1612+
dataset_metadata: list[dict] = []
1613+
seen_dataset_qualified_names: set[str] = set()
1614+
for r in results:
1615+
dataset_qualified_name: str = r.check_collection.soda_qualified_dataset_name
1616+
if dataset_qualified_name in seen_dataset_qualified_names:
1617+
continue
1618+
for check_result in r.check_results:
1619+
# Only a schema check with a discovered (non-empty) column set
1620+
# contributes a metadata entry — an empty schema would soft-delete
1621+
# every column on the backend, so we skip it.
1622+
if not isinstance(check_result, SchemaCheckResult) or not check_result.actual_columns:
1623+
continue
1624+
seen_dataset_qualified_names.add(dataset_qualified_name)
1625+
dataset_metadata.append(
1626+
{
1627+
"datasetQualifiedName": dataset_qualified_name,
1628+
"schema": [
1629+
{
1630+
"columnName": column.column_name,
1631+
"sourceDataType": (column.sql_data_type.name if column.sql_data_type else None),
1632+
}
1633+
for column in check_result.actual_columns
1634+
],
1635+
}
1636+
)
1637+
break
1638+
return dataset_metadata
1639+
1640+
15911641
def _build_check_collection_results_json_dict(
15921642
results: list[ContractVerificationResult],
15931643
wire_source: str = "soda-contract",
@@ -1690,6 +1740,11 @@ def _build_check_collection_results_json_dict(
16901740
"postProcessingStages": post_processing_stages,
16911741
"resultsIngestionMode": ingestion_mode.value,
16921742
"tokenUsage": token_usage,
1743+
# Per-dataset column schema for datasets that ran a schema check,
1744+
# so the backend can refresh the dataset schema in Cloud. ``None``
1745+
# when no schema check produced columns, so ``to_jsonnable`` strips
1746+
# the key entirely (no empty ``metadata`` on the wire).
1747+
"metadata": _build_dataset_metadata_json_dicts(results) or None,
16931748
}
16941749
)
16951750

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Unit tests for the top-level ``metadata`` block in the wire payload.
2+
3+
Locks the shape of the dataset ``metadata`` array that
4+
``_build_check_collection_results_json_dict`` emits when a check collection
5+
runs a schema check. The block lets the backend refresh the dataset's column
6+
schema in Cloud, reusing the ``{columnName, sourceDataType}`` shape v3 discover
7+
emits (DTL-1807).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from datetime import datetime, timezone
13+
14+
from soda_core.common.logs import Location
15+
from soda_core.common.metadata_types import ColumnMetadata, SqlDataType
16+
from soda_core.common.soda_cloud import _build_check_collection_results_json_dict
17+
from soda_core.contracts.contract_verification import (
18+
Check,
19+
CheckCollectionStatus,
20+
CheckOutcome,
21+
CheckResult,
22+
Contract,
23+
ContractVerificationResult,
24+
DataSource,
25+
YamlFileContentInfo,
26+
)
27+
from soda_core.contracts.impl.check_types.schema_check import SchemaCheckResult
28+
29+
30+
def _make_check(*, type: str, relative_path: str) -> Check:
31+
return Check(
32+
column_name=None,
33+
type=type,
34+
qualifier=None,
35+
name=type,
36+
relative_path=relative_path,
37+
identity="abc",
38+
definition=f"{type}: ...",
39+
contract_file_line=1,
40+
contract_file_column=1,
41+
threshold=None,
42+
attributes={},
43+
location=Location(file_path="fake.yml", line=1, column=1),
44+
)
45+
46+
47+
def _column(name: str, sql_data_type: SqlDataType) -> ColumnMetadata:
48+
return ColumnMetadata(column_name=name, sql_data_type=sql_data_type)
49+
50+
51+
def _make_schema_check_result(actual_columns: list[ColumnMetadata]) -> SchemaCheckResult:
52+
return SchemaCheckResult(
53+
check=_make_check(type="schema", relative_path="checks.schema"),
54+
outcome=CheckOutcome.PASSED,
55+
expected_columns=[],
56+
actual_columns=actual_columns,
57+
expected_column_names_not_actual=[],
58+
actual_column_names_not_expected=[],
59+
column_data_type_mismatches=[],
60+
are_columns_out_of_order=False,
61+
)
62+
63+
64+
def _make_row_count_check_result() -> CheckResult:
65+
return CheckResult(
66+
check=_make_check(type="row_count", relative_path="checks.row_count"),
67+
outcome=CheckOutcome.PASSED,
68+
diagnostic_metric_values={"check_rows_tested": 0, "dataset_rows_tested": 0},
69+
)
70+
71+
72+
def _make_result(
73+
*,
74+
soda_qualified_dataset_name: str,
75+
dataset_prefix: list[str],
76+
dataset_name: str,
77+
check_results: list,
78+
) -> ContractVerificationResult:
79+
ts = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
80+
return ContractVerificationResult(
81+
check_collection=Contract(
82+
data_source_name="test_ds",
83+
dataset_prefix=dataset_prefix,
84+
dataset_name=dataset_name,
85+
soda_qualified_dataset_name=soda_qualified_dataset_name,
86+
source=YamlFileContentInfo(
87+
source_content_str="# c",
88+
local_file_path="/fake/c.yml",
89+
soda_cloud_file_id="file-c",
90+
),
91+
),
92+
data_source=DataSource(name="test_ds", type="postgres"),
93+
data_timestamp=ts,
94+
started_timestamp=ts,
95+
ended_timestamp=ts,
96+
status=CheckCollectionStatus.PASSED,
97+
measurements=[],
98+
check_results=check_results,
99+
sending_results_to_soda_cloud_failed=False,
100+
log_records=[],
101+
post_processing_stages=[],
102+
)
103+
104+
105+
def test_metadata_present_with_schema_of_column_name_and_source_data_type():
106+
"""A collection that ran a schema check → one ``metadata`` entry whose
107+
``schema`` mirrors the discovered columns as ``{columnName, sourceDataType}``."""
108+
result = _make_result(
109+
soda_qualified_dataset_name="postgres/public/customers",
110+
dataset_prefix=["public"],
111+
dataset_name="customers",
112+
check_results=[
113+
_make_schema_check_result(
114+
actual_columns=[
115+
_column("id", SqlDataType(name="integer")),
116+
_column("name", SqlDataType(name="character varying", character_maximum_length=255)),
117+
]
118+
)
119+
],
120+
)
121+
122+
payload = _build_check_collection_results_json_dict([result], wire_source="soda-contract")
123+
124+
assert payload["metadata"] == [
125+
{
126+
"datasetQualifiedName": "postgres/public/customers",
127+
"schema": [
128+
{"columnName": "id", "sourceDataType": "integer"},
129+
{"columnName": "name", "sourceDataType": "character varying"},
130+
],
131+
}
132+
]
133+
134+
135+
def test_metadata_source_data_type_is_unparameterized():
136+
"""``sourceDataType`` is the raw un-parameterized type (``character
137+
varying``), NOT the parameterized ``character varying(255)`` form."""
138+
result = _make_result(
139+
soda_qualified_dataset_name="postgres/public/customers",
140+
dataset_prefix=["public"],
141+
dataset_name="customers",
142+
check_results=[
143+
_make_schema_check_result(
144+
actual_columns=[_column("name", SqlDataType(name="character varying", character_maximum_length=255))]
145+
)
146+
],
147+
)
148+
payload = _build_check_collection_results_json_dict([result], wire_source="soda-contract")
149+
assert payload["metadata"][0]["schema"][0]["sourceDataType"] == "character varying"
150+
151+
152+
def test_metadata_absent_when_no_schema_check():
153+
"""No schema check → no ``metadata`` key at all (``to_jsonnable`` strips it)."""
154+
result = _make_result(
155+
soda_qualified_dataset_name="postgres/public/customers",
156+
dataset_prefix=["public"],
157+
dataset_name="customers",
158+
check_results=[_make_row_count_check_result()],
159+
)
160+
payload = _build_check_collection_results_json_dict([result], wire_source="soda-contract")
161+
assert "metadata" not in payload
162+
163+
164+
def test_metadata_absent_when_schema_check_has_no_columns():
165+
"""A schema check that discovered no columns (missing dataset) → no
166+
``metadata`` entry. We never send an empty schema — that would soft-delete
167+
every column on the backend."""
168+
result = _make_result(
169+
soda_qualified_dataset_name="postgres/public/customers",
170+
dataset_prefix=["public"],
171+
dataset_name="customers",
172+
check_results=[_make_schema_check_result(actual_columns=[])],
173+
)
174+
payload = _build_check_collection_results_json_dict([result], wire_source="soda-contract")
175+
assert "metadata" not in payload
176+
177+
178+
def test_metadata_one_entry_per_dataset_in_session():
179+
"""A combined multi-file session emits one ``metadata`` entry per distinct
180+
dataset that ran a schema check — prefixed and non-prefixed dqns alike."""
181+
prefixed = _make_result(
182+
soda_qualified_dataset_name="postgres/public/customers",
183+
dataset_prefix=["public"],
184+
dataset_name="customers",
185+
check_results=[_make_schema_check_result(actual_columns=[_column("id", SqlDataType(name="integer"))])],
186+
)
187+
non_prefixed = _make_result(
188+
soda_qualified_dataset_name="postgres/orders",
189+
dataset_prefix=[],
190+
dataset_name="orders",
191+
check_results=[_make_schema_check_result(actual_columns=[_column("total", SqlDataType(name="numeric"))])],
192+
)
193+
no_schema = _make_result(
194+
soda_qualified_dataset_name="postgres/public/events",
195+
dataset_prefix=["public"],
196+
dataset_name="events",
197+
check_results=[_make_row_count_check_result()],
198+
)
199+
200+
payload = _build_check_collection_results_json_dict(
201+
[prefixed, non_prefixed, no_schema], wire_source="data-standard"
202+
)
203+
204+
assert payload["metadata"] == [
205+
{
206+
"datasetQualifiedName": "postgres/public/customers",
207+
"schema": [{"columnName": "id", "sourceDataType": "integer"}],
208+
},
209+
{
210+
"datasetQualifiedName": "postgres/orders",
211+
"schema": [{"columnName": "total", "sourceDataType": "numeric"}],
212+
},
213+
]

0 commit comments

Comments
 (0)