mikeboone Claude Fable 5 commited on
Commit
fa79ccd
·
1 Parent(s): 9143045

feat: post-load data-quality gate for zero/null measure columns

Browse files

Profile 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")