Spaces:
Running
Running
feat: post-load data-quality gate for zero/null measure columns
Browse filesProfile every MEASURE column after each Snowflake load (both dataset-first
and legacy paths) via INFORMATION_SCHEMA + per-table aggregates. Fail the
run loudly, naming the columns, when any measure is entirely zero/null
(recurring Yodeck/Triumph zero-measure defect). Mostly-zero and empty-table
cases surface as warnings in the completion panel and session_logs meta.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chat_interface.py
CHANGED
|
@@ -3488,6 +3488,7 @@ LegitData will generate realistic, AI-powered data.
|
|
| 3488 |
|
| 3489 |
# Clear and initialize live progress for Snowflake deployment
|
| 3490 |
self.live_progress_log = ["=" * 60, "SNOWFLAKE DEPLOYMENT STARTING", "=" * 60, ""]
|
|
|
|
| 3491 |
|
| 3492 |
def log_progress(msg):
|
| 3493 |
"""Log to live progress tab only — not pipeline status"""
|
|
@@ -3717,13 +3718,67 @@ LegitData will generate realistic, AI-powered data.
|
|
| 3717 |
|
| 3718 |
progress += f"\n[OK] Data populated"
|
| 3719 |
log_progress(f"[OK] {pop_message}")
|
| 3720 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3721 |
self._deployed_schema_name = schema_name
|
| 3722 |
_deploy_meta = {
|
| 3723 |
"schema_name": schema_name,
|
| 3724 |
"generation_mode": "dataset_first" if self._dataset_first_bundle else "legacy",
|
| 3725 |
"table_names": [table.name for table in self._dataset_first_bundle.tables] if self._dataset_first_bundle else [],
|
| 3726 |
"row_counts": {table.name: len(table.rows) for table in self._dataset_first_bundle.tables} if self._dataset_first_bundle else {},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3727 |
}
|
| 3728 |
|
| 3729 |
log_progress("")
|
|
@@ -4468,7 +4523,13 @@ The liveboard GUID couldn't be retrieved — it may still have been created in T
|
|
| 4468 |
- Type **'retry liveboard'** to try building it again
|
| 4469 |
|
| 4470 |
Type **'done'** to finish."""
|
| 4471 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4472 |
yield {'response': response, 'stage': final_stage}
|
| 4473 |
else:
|
| 4474 |
errors = results.get('errors', ['Unknown error'])
|
|
|
|
| 3488 |
|
| 3489 |
# Clear and initialize live progress for Snowflake deployment
|
| 3490 |
self.live_progress_log = ["=" * 60, "SNOWFLAKE DEPLOYMENT STARTING", "=" * 60, ""]
|
| 3491 |
+
self._dq_warnings = [] # reset data-quality gate warnings for this run
|
| 3492 |
|
| 3493 |
def log_progress(msg):
|
| 3494 |
"""Log to live progress tab only — not pipeline status"""
|
|
|
|
| 3718 |
|
| 3719 |
progress += f"\n[OK] Data populated"
|
| 3720 |
log_progress(f"[OK] {pop_message}")
|
| 3721 |
+
|
| 3722 |
+
# Data-quality gate: after every load, profile the MEASURE columns
|
| 3723 |
+
# that were actually written to Snowflake and fail loudly if any is
|
| 3724 |
+
# entirely zero/null (recurring zero-measure defect — derived
|
| 3725 |
+
# measures like TOTAL_*_USD loading as all zeros → blank tiles).
|
| 3726 |
+
from demoprep_app.integrations.snowflake import (
|
| 3727 |
+
ZeroMeasureError,
|
| 3728 |
+
run_measure_quality_gate,
|
| 3729 |
+
)
|
| 3730 |
+
progress += f"\n\n**Verifying data quality (measure columns)...**"
|
| 3731 |
+
yield progress
|
| 3732 |
+
log_progress("")
|
| 3733 |
+
log_progress("Running data-quality gate on measure columns...")
|
| 3734 |
+
try:
|
| 3735 |
+
dq_profile = run_measure_quality_gate(
|
| 3736 |
+
deployer.connection, schema_name, progress_callback=log_progress
|
| 3737 |
+
)
|
| 3738 |
+
except ZeroMeasureError as e:
|
| 3739 |
+
self._last_population_error = str(e)
|
| 3740 |
+
self._last_schema_name = schema_name
|
| 3741 |
+
_deploy_meta = {
|
| 3742 |
+
"schema_name": schema_name,
|
| 3743 |
+
"data_quality": {
|
| 3744 |
+
"failures": e.failures,
|
| 3745 |
+
"warnings": e.profile.get("warnings", []),
|
| 3746 |
+
},
|
| 3747 |
+
}
|
| 3748 |
+
log_progress(f"[ERROR] {e}")
|
| 3749 |
+
log_progress(f" Schema {schema_name} left in place for inspection.")
|
| 3750 |
+
if _slog:
|
| 3751 |
+
_slog.log("deploy", "data quality gate failed", error=str(e))
|
| 3752 |
+
raise Exception(str(e))
|
| 3753 |
+
|
| 3754 |
+
dq_warnings = dq_profile.get("warnings", [])
|
| 3755 |
+
self._dq_warnings = dq_warnings
|
| 3756 |
+
progress += (
|
| 3757 |
+
f"\n[OK] Data quality gate passed "
|
| 3758 |
+
f"({dq_profile['measure_columns_checked']} measure columns "
|
| 3759 |
+
f"across {dq_profile['tables_checked']} tables)"
|
| 3760 |
+
)
|
| 3761 |
+
for _dq_w in dq_warnings:
|
| 3762 |
+
progress += f"\n[WARN] {_dq_w}"
|
| 3763 |
+
if _slog:
|
| 3764 |
+
_slog.log_verbose(
|
| 3765 |
+
"deploy", "data quality gate passed",
|
| 3766 |
+
measure_columns_checked=dq_profile["measure_columns_checked"],
|
| 3767 |
+
tables_checked=dq_profile["tables_checked"],
|
| 3768 |
+
dq_warnings=dq_warnings,
|
| 3769 |
+
)
|
| 3770 |
+
|
| 3771 |
self._deployed_schema_name = schema_name
|
| 3772 |
_deploy_meta = {
|
| 3773 |
"schema_name": schema_name,
|
| 3774 |
"generation_mode": "dataset_first" if self._dataset_first_bundle else "legacy",
|
| 3775 |
"table_names": [table.name for table in self._dataset_first_bundle.tables] if self._dataset_first_bundle else [],
|
| 3776 |
"row_counts": {table.name: len(table.rows) for table in self._dataset_first_bundle.tables} if self._dataset_first_bundle else {},
|
| 3777 |
+
"data_quality": {
|
| 3778 |
+
"measure_columns_checked": dq_profile["measure_columns_checked"],
|
| 3779 |
+
"tables_checked": dq_profile["tables_checked"],
|
| 3780 |
+
"warnings": dq_warnings,
|
| 3781 |
+
},
|
| 3782 |
}
|
| 3783 |
|
| 3784 |
log_progress("")
|
|
|
|
| 4523 |
- Type **'retry liveboard'** to try building it again
|
| 4524 |
|
| 4525 |
Type **'done'** to finish."""
|
| 4526 |
+
|
| 4527 |
+
# Surface data-quality warnings from the load gate in the completion panel
|
| 4528 |
+
_dq_warnings = getattr(self, '_dq_warnings', None)
|
| 4529 |
+
if _dq_warnings:
|
| 4530 |
+
response += "\n\n---\n\n**⚠️ Data quality warnings:**\n" + \
|
| 4531 |
+
"\n".join(f"- {w}" for w in _dq_warnings)
|
| 4532 |
+
|
| 4533 |
yield {'response': response, 'stage': final_stage}
|
| 4534 |
else:
|
| 4535 |
errors = results.get('errors', ['Unknown error'])
|
demoprep_app/integrations/snowflake/__init__.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
| 1 |
"""Snowflake integration helpers."""
|
| 2 |
|
| 3 |
from demoprep_app.integrations.snowflake.dataset_writer import populate_dataset_bundle
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
__all__ = ["populate_dataset_bundle"]
|
| 6 |
|
|
|
|
| 1 |
"""Snowflake integration helpers."""
|
| 2 |
|
| 3 |
from demoprep_app.integrations.snowflake.dataset_writer import populate_dataset_bundle
|
| 4 |
+
from demoprep_app.integrations.snowflake.quality_gate import (
|
| 5 |
+
ZeroMeasureError,
|
| 6 |
+
run_measure_quality_gate,
|
| 7 |
+
)
|
| 8 |
|
| 9 |
+
__all__ = ["populate_dataset_bundle", "run_measure_quality_gate", "ZeroMeasureError"]
|
| 10 |
|
demoprep_app/integrations/snowflake/quality_gate.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-load data-quality gate for generated demo schemas.
|
| 2 |
+
|
| 3 |
+
Guards against the recurring zero-measure defect: fact tables where derived
|
| 4 |
+
measure columns (e.g. TOTAL_LOAD_REVENUE_USD, BROKER_MARGIN_USD) load as 100%
|
| 5 |
+
zero/null while their input columns are populated, which renders liveboard
|
| 6 |
+
tiles blank. Runs immediately after data population, against what was actually
|
| 7 |
+
loaded into Snowflake, so it covers both the dataset-first and legacy
|
| 8 |
+
LegitData paths.
|
| 9 |
+
|
| 10 |
+
The gate profiles every MEASURE column (numeric columns that are not join
|
| 11 |
+
keys, mirroring ThoughtSpotDeployer._determine_column_type) and raises
|
| 12 |
+
ZeroMeasureError when any measure is entirely zero/null. No silent fallback:
|
| 13 |
+
a failed gate fails the run with the offending columns named.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import Any, Callable, Optional
|
| 19 |
+
|
| 20 |
+
# Snowflake normalizes numeric types, but accept the common aliases too.
|
| 21 |
+
_NUMERIC_DATA_TYPES = {
|
| 22 |
+
"NUMBER", "DECIMAL", "NUMERIC", "INT", "INTEGER", "BIGINT", "SMALLINT",
|
| 23 |
+
"TINYINT", "BYTEINT", "FLOAT", "FLOAT4", "FLOAT8", "DOUBLE",
|
| 24 |
+
"DOUBLE PRECISION", "REAL",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# Share of non-zero values below which a populated measure column is suspicious
|
| 28 |
+
# (a partial variant of the all-zero defect) but not an outright failure.
|
| 29 |
+
MOSTLY_ZERO_WARN_THRESHOLD = 0.01
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ZeroMeasureError(Exception):
|
| 33 |
+
"""One or more measure columns loaded entirely zero/null."""
|
| 34 |
+
|
| 35 |
+
def __init__(self, failures: list[dict], profile: dict):
|
| 36 |
+
self.failures = failures
|
| 37 |
+
self.profile = profile
|
| 38 |
+
details = "; ".join(
|
| 39 |
+
f"{f['table']}.{f['column']} ({f['row_count']} rows, all zero/null)"
|
| 40 |
+
for f in failures
|
| 41 |
+
)
|
| 42 |
+
super().__init__(
|
| 43 |
+
f"Data quality gate failed — {len(failures)} measure column(s) are "
|
| 44 |
+
f"entirely zero/null: {details}. The data generator did not compute "
|
| 45 |
+
f"these derived measures; the run must be regenerated."
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def is_measure_column(column_name: str, data_type: str) -> bool:
|
| 50 |
+
"""Mirror ThoughtSpotDeployer._determine_column_type's MEASURE test."""
|
| 51 |
+
base_type = data_type.upper().split("(")[0].strip()
|
| 52 |
+
if base_type not in _NUMERIC_DATA_TYPES:
|
| 53 |
+
return False
|
| 54 |
+
col_upper = column_name.upper()
|
| 55 |
+
# Join keys are ATTRIBUTEs in the model, not measures.
|
| 56 |
+
if col_upper.endswith("ID") or col_upper.endswith("KEY") or col_upper.endswith("_CODE"):
|
| 57 |
+
return False
|
| 58 |
+
return True
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _quote_ident(identifier: str) -> str:
|
| 62 |
+
escaped = identifier.replace('"', '""')
|
| 63 |
+
return f'"{escaped}"'
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def run_measure_quality_gate(
|
| 67 |
+
connection: Any,
|
| 68 |
+
schema_name: str,
|
| 69 |
+
progress_callback: Optional[Callable[[str], None]] = None,
|
| 70 |
+
) -> dict:
|
| 71 |
+
"""Profile every measure column in the schema; fail loudly on all-zero ones.
|
| 72 |
+
|
| 73 |
+
Returns a profile dict:
|
| 74 |
+
{
|
| 75 |
+
"tables_checked": int,
|
| 76 |
+
"measure_columns_checked": int,
|
| 77 |
+
"failures": [], # empty on success (non-empty raises instead)
|
| 78 |
+
"warnings": ["FACT_X.COL is 99.8% zero/null (1/500 non-zero)", ...],
|
| 79 |
+
"columns": {"TABLE.COLUMN": {"row_count": n, "non_zero": m}, ...},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
Raises ZeroMeasureError when any measure column in a populated table is
|
| 83 |
+
entirely zero/null. Profiling errors (bad schema name, connection loss)
|
| 84 |
+
propagate as-is — the gate never passes silently.
|
| 85 |
+
"""
|
| 86 |
+
def log(msg: str) -> None:
|
| 87 |
+
if progress_callback:
|
| 88 |
+
progress_callback(msg)
|
| 89 |
+
|
| 90 |
+
cursor = connection.cursor()
|
| 91 |
+
try:
|
| 92 |
+
cursor.execute(
|
| 93 |
+
"""
|
| 94 |
+
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE
|
| 95 |
+
FROM INFORMATION_SCHEMA.COLUMNS
|
| 96 |
+
WHERE TABLE_SCHEMA = %s
|
| 97 |
+
ORDER BY TABLE_NAME, ORDINAL_POSITION
|
| 98 |
+
""",
|
| 99 |
+
(schema_name,),
|
| 100 |
+
)
|
| 101 |
+
rows = cursor.fetchall()
|
| 102 |
+
if not rows:
|
| 103 |
+
raise RuntimeError(
|
| 104 |
+
f"Data quality gate could not find any columns for schema "
|
| 105 |
+
f"'{schema_name}' in INFORMATION_SCHEMA — cannot verify the load."
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
measures_by_table: dict[str, list[str]] = {}
|
| 109 |
+
for table_name, column_name, data_type in rows:
|
| 110 |
+
if is_measure_column(column_name, data_type or ""):
|
| 111 |
+
measures_by_table.setdefault(table_name, []).append(column_name)
|
| 112 |
+
|
| 113 |
+
profile: dict = {
|
| 114 |
+
"tables_checked": 0,
|
| 115 |
+
"measure_columns_checked": 0,
|
| 116 |
+
"failures": [],
|
| 117 |
+
"warnings": [],
|
| 118 |
+
"columns": {},
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
for table_name, columns in sorted(measures_by_table.items()):
|
| 122 |
+
select_parts = ["COUNT(*)"]
|
| 123 |
+
for col in columns:
|
| 124 |
+
q = _quote_ident(col)
|
| 125 |
+
select_parts.append(f"SUM(IFF({q} IS NOT NULL AND {q} <> 0, 1, 0))")
|
| 126 |
+
cursor.execute(
|
| 127 |
+
f"SELECT {', '.join(select_parts)} "
|
| 128 |
+
f"FROM {_quote_ident(schema_name)}.{_quote_ident(table_name)}"
|
| 129 |
+
)
|
| 130 |
+
result = cursor.fetchone()
|
| 131 |
+
row_count = int(result[0] or 0)
|
| 132 |
+
|
| 133 |
+
profile["tables_checked"] += 1
|
| 134 |
+
if row_count == 0:
|
| 135 |
+
profile["warnings"].append(
|
| 136 |
+
f"{table_name} has 0 rows — measure columns could not be verified"
|
| 137 |
+
)
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
for idx, col in enumerate(columns):
|
| 141 |
+
non_zero = int(result[idx + 1] or 0)
|
| 142 |
+
profile["measure_columns_checked"] += 1
|
| 143 |
+
profile["columns"][f"{table_name}.{col}"] = {
|
| 144 |
+
"row_count": row_count,
|
| 145 |
+
"non_zero": non_zero,
|
| 146 |
+
}
|
| 147 |
+
if non_zero == 0:
|
| 148 |
+
profile["failures"].append(
|
| 149 |
+
{"table": table_name, "column": col, "row_count": row_count}
|
| 150 |
+
)
|
| 151 |
+
elif non_zero / row_count < MOSTLY_ZERO_WARN_THRESHOLD:
|
| 152 |
+
pct_zero = 100.0 * (1 - non_zero / row_count)
|
| 153 |
+
profile["warnings"].append(
|
| 154 |
+
f"{table_name}.{col} is {pct_zero:.1f}% zero/null "
|
| 155 |
+
f"({non_zero}/{row_count} non-zero)"
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
log(
|
| 159 |
+
f"Data quality gate: profiled {profile['measure_columns_checked']} measure "
|
| 160 |
+
f"column(s) across {profile['tables_checked']} table(s)"
|
| 161 |
+
)
|
| 162 |
+
for warning in profile["warnings"]:
|
| 163 |
+
log(f"[WARN] Data quality: {warning}")
|
| 164 |
+
|
| 165 |
+
if profile["failures"]:
|
| 166 |
+
raise ZeroMeasureError(profile["failures"], profile)
|
| 167 |
+
|
| 168 |
+
return profile
|
| 169 |
+
finally:
|
| 170 |
+
cursor.close()
|
sprint_2026_04.md
CHANGED
|
@@ -221,6 +221,7 @@ should tell. KPI targets, growth trends, and anomaly patterns live in the matrix
|
|
| 221 |
- [x] **AI Viz Titles** — `_humanize_viz_titles()` in `liveboard_creator.py`; one LLM call renames all raw TS column-name titles to business-readable labels; runs as Step 6.5 in `enhance_mcp_liveboard()` before TML re-import ✅
|
| 222 |
- [x] **KPI conversion fix** — Step 3.5 no longer promotes "by X" dimensional breakdowns to KPIs (was putting "Averagesellingprice by Category Weekly" in the Key Metrics group) ✅
|
| 223 |
- [x] **Chart variety** — multi-dim breakdowns ("by X and Y") → STACKED_COLUMN; single-dim categorical breakdowns now catches LINE charts (MCP often generates LINE for categoricals, blocking donut conversion) ✅
|
|
|
|
| 224 |
|
| 225 |
### Shipped at end of Sprint 3 / mini sprint (Apr 28-29)
|
| 226 |
|
|
|
|
| 221 |
- [x] **AI Viz Titles** — `_humanize_viz_titles()` in `liveboard_creator.py`; one LLM call renames all raw TS column-name titles to business-readable labels; runs as Step 6.5 in `enhance_mcp_liveboard()` before TML re-import ✅
|
| 222 |
- [x] **KPI conversion fix** — Step 3.5 no longer promotes "by X" dimensional breakdowns to KPIs (was putting "Averagesellingprice by Category Weekly" in the Key Metrics group) ✅
|
| 223 |
- [x] **Chart variety** — multi-dim breakdowns ("by X and Y") → STACKED_COLUMN; single-dim categorical breakdowns now catches LINE charts (MCP often generates LINE for categoricals, blocking donut conversion) ✅
|
| 224 |
+
- [x] **Post-load measure data-quality gate** (Aug 25) — `demoprep_app/integrations/snowflake/quality_gate.py`; runs in `run_deployment_streaming()` right after population (both dataset-first and legacy paths); profiles every MEASURE column via INFORMATION_SCHEMA + per-table aggregates; raises `ZeroMeasureError` naming the columns when any measure loads entirely zero/null (Yodeck/Triumph zero-measure defect); mostly-zero (<1% non-zero) and empty tables → warnings surfaced in completion panel + `_deploy_meta.data_quality` in session_logs for grading; unit tests in `tests/test_quality_gate.py` ✅
|
| 225 |
|
| 226 |
### Shipped at end of Sprint 3 / mini sprint (Apr 28-29)
|
| 227 |
|
tests/test_quality_gate.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the post-load measure data-quality gate.
|
| 2 |
+
|
| 3 |
+
Run with: pytest tests/test_quality_gate.py -v
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from demoprep_app.integrations.snowflake.quality_gate import (
|
| 9 |
+
ZeroMeasureError,
|
| 10 |
+
is_measure_column,
|
| 11 |
+
run_measure_quality_gate,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class FakeCursor:
|
| 16 |
+
"""Answers the INFORMATION_SCHEMA query, then per-table aggregate queries."""
|
| 17 |
+
|
| 18 |
+
def __init__(self, columns_rows, table_aggregates):
|
| 19 |
+
# columns_rows: list of (table_name, column_name, data_type)
|
| 20 |
+
# table_aggregates: {table_name: (row_count, nonzero_col1, nonzero_col2, ...)}
|
| 21 |
+
self._columns_rows = columns_rows
|
| 22 |
+
self._table_aggregates = table_aggregates
|
| 23 |
+
self._last_result = None
|
| 24 |
+
self.executed = []
|
| 25 |
+
|
| 26 |
+
def execute(self, sql, params=None):
|
| 27 |
+
self.executed.append(sql)
|
| 28 |
+
if "INFORMATION_SCHEMA.COLUMNS" in sql:
|
| 29 |
+
self._last_result = self._columns_rows
|
| 30 |
+
else:
|
| 31 |
+
table = next(t for t in self._table_aggregates if f'"{t}"' in sql)
|
| 32 |
+
self._last_result = self._table_aggregates[table]
|
| 33 |
+
|
| 34 |
+
def fetchall(self):
|
| 35 |
+
return self._last_result
|
| 36 |
+
|
| 37 |
+
def fetchone(self):
|
| 38 |
+
return self._last_result
|
| 39 |
+
|
| 40 |
+
def close(self):
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class FakeConnection:
|
| 45 |
+
def __init__(self, cursor):
|
| 46 |
+
self._cursor = cursor
|
| 47 |
+
|
| 48 |
+
def cursor(self):
|
| 49 |
+
return self._cursor
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_is_measure_column_heuristic():
|
| 53 |
+
# Numeric non-key columns are measures
|
| 54 |
+
assert is_measure_column("TOTAL_LOAD_REVENUE_USD", "NUMBER(12,2)")
|
| 55 |
+
assert is_measure_column("BROKER_MARGIN_PCT", "FLOAT")
|
| 56 |
+
# Join keys and codes are not measures
|
| 57 |
+
assert not is_measure_column("LOAD_ID", "NUMBER(38,0)")
|
| 58 |
+
assert not is_measure_column("LANE_KEY", "NUMBER(38,0)")
|
| 59 |
+
assert not is_measure_column("CARRIER_CODE", "NUMBER(38,0)")
|
| 60 |
+
# Non-numeric columns are not measures
|
| 61 |
+
assert not is_measure_column("ORIGIN_CITY", "VARCHAR(100)")
|
| 62 |
+
assert not is_measure_column("PICKUP_DATE", "DATE")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_gate_fails_loudly_on_all_zero_measure():
|
| 66 |
+
cursor = FakeCursor(
|
| 67 |
+
columns_rows=[
|
| 68 |
+
("FACT_LOAD_TRANSACTION", "LOAD_ID", "NUMBER"),
|
| 69 |
+
("FACT_LOAD_TRANSACTION", "LOADS_ACCEPTED", "NUMBER"),
|
| 70 |
+
("FACT_LOAD_TRANSACTION", "TOTAL_LOAD_REVENUE_USD", "NUMBER"),
|
| 71 |
+
("FACT_LOAD_TRANSACTION", "BROKER_MARGIN_USD", "NUMBER"),
|
| 72 |
+
],
|
| 73 |
+
# 500 rows: LOADS_ACCEPTED populated, both derived measures all zero
|
| 74 |
+
table_aggregates={"FACT_LOAD_TRANSACTION": (500, 500, 0, 0)},
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
with pytest.raises(ZeroMeasureError) as exc_info:
|
| 78 |
+
run_measure_quality_gate(FakeConnection(cursor), "TRI_TEST_sch")
|
| 79 |
+
|
| 80 |
+
err = exc_info.value
|
| 81 |
+
assert len(err.failures) == 2
|
| 82 |
+
failed_columns = {f["column"] for f in err.failures}
|
| 83 |
+
assert failed_columns == {"TOTAL_LOAD_REVENUE_USD", "BROKER_MARGIN_USD"}
|
| 84 |
+
# Error message must name the offending columns (fail loudly, no guessing)
|
| 85 |
+
assert "FACT_LOAD_TRANSACTION.TOTAL_LOAD_REVENUE_USD" in str(err)
|
| 86 |
+
assert "FACT_LOAD_TRANSACTION.BROKER_MARGIN_USD" in str(err)
|
| 87 |
+
# ID column must not be profiled as a measure
|
| 88 |
+
assert not any(f["column"] == "LOAD_ID" for f in err.failures)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_gate_passes_with_populated_measures():
|
| 92 |
+
cursor = FakeCursor(
|
| 93 |
+
columns_rows=[
|
| 94 |
+
("FACT_SALES", "SALE_ID", "NUMBER"),
|
| 95 |
+
("FACT_SALES", "REVENUE_USD", "NUMBER"),
|
| 96 |
+
("DIM_PRODUCT", "PRODUCT_ID", "NUMBER"),
|
| 97 |
+
("DIM_PRODUCT", "UNIT_PRICE", "NUMBER"),
|
| 98 |
+
],
|
| 99 |
+
table_aggregates={
|
| 100 |
+
"FACT_SALES": (500, 498),
|
| 101 |
+
"DIM_PRODUCT": (50, 50),
|
| 102 |
+
},
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
profile = run_measure_quality_gate(FakeConnection(cursor), "SALES_sch")
|
| 106 |
+
assert profile["failures"] == []
|
| 107 |
+
assert profile["tables_checked"] == 2
|
| 108 |
+
assert profile["measure_columns_checked"] == 2
|
| 109 |
+
assert profile["columns"]["FACT_SALES.REVENUE_USD"] == {
|
| 110 |
+
"row_count": 500,
|
| 111 |
+
"non_zero": 498,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_gate_warns_on_mostly_zero_measure():
|
| 116 |
+
cursor = FakeCursor(
|
| 117 |
+
columns_rows=[("FACT_SALES", "DISCOUNT_USD", "NUMBER")],
|
| 118 |
+
# 1 non-zero value in 500 rows (0.2%) — suspicious but not a failure
|
| 119 |
+
table_aggregates={"FACT_SALES": (500, 1)},
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
profile = run_measure_quality_gate(FakeConnection(cursor), "SALES_sch")
|
| 123 |
+
assert profile["failures"] == []
|
| 124 |
+
assert len(profile["warnings"]) == 1
|
| 125 |
+
assert "FACT_SALES.DISCOUNT_USD" in profile["warnings"][0]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_gate_warns_on_empty_table():
|
| 129 |
+
cursor = FakeCursor(
|
| 130 |
+
columns_rows=[("FACT_SALES", "REVENUE_USD", "NUMBER")],
|
| 131 |
+
table_aggregates={"FACT_SALES": (0, 0)},
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
profile = run_measure_quality_gate(FakeConnection(cursor), "SALES_sch")
|
| 135 |
+
assert profile["failures"] == []
|
| 136 |
+
assert any("0 rows" in w for w in profile["warnings"])
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_gate_errors_when_schema_has_no_columns():
|
| 140 |
+
cursor = FakeCursor(columns_rows=[], table_aggregates={})
|
| 141 |
+
with pytest.raises(RuntimeError, match="could not find any columns"):
|
| 142 |
+
run_measure_quality_gate(FakeConnection(cursor), "MISSING_sch")
|