File size: 5,215 Bytes
a89fce0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
"""Semantic data-quality gate β€” run between data load and liveboard creation.

Motivation (Yodeck, Aug 20 2026): the generator wrote every derived measure
(ARR, Net New MRR, NRR, collection/margin rates) as all-zero, and the liveboard
charted exactly those columns β€” 6 of 10 vizzes rendered nothing or $0. Nothing
in the pipeline checked that charted data meant anything: every gate was a
plumbing gate (DDL compiled, rows loaded, TML imported).

This module is the semantic gate:
  * scan_dead_measures(database, schema): one aggregate query per table finds
    numeric columns that are entirely NULL/zero ("dead") or constant.
  * filter_model_columns(model_columns, dead_names): drops model MEASURE
    columns whose physical column is dead, so question/KPI/viz generation can
    only reach measures with real data.

Fail-open by design: any error here must never break a build β€” callers wrap in
try/except and proceed unfiltered with a warning.
"""

from __future__ import annotations

import re
from typing import Dict, List, Tuple

# Numeric columns that are join/surrogate keys, not measures β€” never scanned.
_KEY_SUFFIXES = ("_KEY", "_ID")

# Snowflake numeric types eligible for the dead-measure scan.
_NUMERIC_TYPES = ("NUMBER", "FLOAT", "FIXED", "REAL", "DECIMAL", "NUMERIC", "INT")


def _normalize(name: str) -> str:
    """Match model display names to physical columns: 'Net New Mrr' == NET_NEW_MRR."""
    return re.sub(r"[^a-z0-9]", "", (name or "").lower())


def scan_dead_measures(database: str, schema: str, log=None) -> Dict:
    """Scan every base table in the schema for dead numeric measure columns.

    Returns {
      'dead':     {table: [column, ...]},   # all rows NULL or zero
      'constant': {table: [column, ...]},   # single repeated non-zero value
      'dead_names': set of normalized dead column names (for model filtering),
      'warnings': [str, ...],
    }
    Opens its own Snowflake connection (keypair auth via snowflake_auth).
    """
    def _log(msg: str) -> None:
        if log:
            log(msg)

    from snowflake_auth import get_snowflake_connection

    out = {"dead": {}, "constant": {}, "dead_names": set(), "warnings": []}
    conn = get_snowflake_connection()
    try:
        cur = conn.cursor()
        cur.execute(
            f"""
            select c.table_name, c.column_name
            from {database}.information_schema.columns c
            join {database}.information_schema.tables t
              on t.table_schema = c.table_schema and t.table_name = c.table_name
            where c.table_schema = %s
              and t.table_type = 'BASE TABLE'
              and c.data_type in ({','.join("'" + t + "'" for t in _NUMERIC_TYPES)})
            order by c.table_name, c.ordinal_position
            """,
            (schema,),
        )
        by_table: Dict[str, List[str]] = {}
        for table, column in cur.fetchall():
            if column.upper().endswith(_KEY_SUFFIXES):
                continue
            by_table.setdefault(table, []).append(column)

        for table, cols in by_table.items():
            # One aggregate pass per table: [nonzero-count, min, max] per column.
            selects = []
            for c in cols:
                selects.append(f'count_if("{c}" is not null and "{c}" <> 0)')
                selects.append(f'min("{c}")')
                selects.append(f'max("{c}")')
            cur.execute(
                f'select count(*), {", ".join(selects)} from {database}."{schema}"."{table}"'
            )
            row = cur.fetchone()
            total = row[0]
            if not total:
                continue
            for i, c in enumerate(cols):
                nonzero, cmin, cmax = row[1 + i * 3], row[2 + i * 3], row[3 + i * 3]
                if nonzero == 0:
                    out["dead"].setdefault(table, []).append(c)
                    out["dead_names"].add(_normalize(c))
                elif total > 1 and cmin == cmax:
                    out["constant"].setdefault(table, []).append(c)

        for table, cols in out["dead"].items():
            msg = f"DATA GATE: {table} has all-zero/all-null measures: {', '.join(cols)}"
            out["warnings"].append(msg)
            _log(f"   [GATE] {msg}")
        for table, cols in out["constant"].items():
            msg = f"DATA GATE: {table} has constant-value measures: {', '.join(cols)}"
            out["warnings"].append(msg)
            _log(f"   [GATE] {msg}")
        if not out["dead"] and not out["constant"]:
            _log("   [GATE] Data gate clean: no dead or constant measures")
    finally:
        conn.close()
    return out


def filter_model_columns(model_columns: List[Dict], dead_names) -> Tuple[List[Dict], List[str]]:
    """Split model columns into (kept, excluded_names). Only MEASURE-typed
    columns are eligible for exclusion β€” attributes/dates always pass."""
    kept, excluded = [], []
    for col in model_columns or []:
        name = col.get("name") or ""
        ctype = (col.get("type") or "").upper()
        if ctype == "MEASURE" and _normalize(name) in dead_names:
            excluded.append(name)
        else:
            kept.append(col)
    return kept, excluded