Skip to content

Commit bf0b444

Browse files
committed
Stop LookML export from silently turning measures into COUNT
The measure export fell back to type_mapping.get(metric.agg, "count"), so any aggregation Looker has no entry for was emitted as type: count -- a silent change to a row count that round-trips back as agg=count: - median / stddev / variance exported as COUNT(col) - complex metric types (cumulative/conversion/retention/cohort, agg=None) exported as COUNT(*) Map median to Looker's native type, emit stddev/variance as type: number with an explicit SQL aggregate, export agg-less opaque measures as type: number, and skip-with-warning anything with no LookML equivalent instead of defaulting to count.
1 parent 459b126 commit bf0b444

2 files changed

Lines changed: 1363 additions & 19 deletions

File tree

sidemantic/adapters/lookml.py

Lines changed: 275 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -447,10 +447,26 @@ def _range_sql_negated(bounds, col: str) -> str:
447447
return conds[0] if len(conds) == 1 else "(" + " OR ".join(conds) + ")"
448448

449449
@staticmethod
450-
def _split_top_level_commas(s: str) -> list[str]:
451-
"""Split on commas that are NOT inside ``[...]``/``(...)`` brackets."""
452-
out, cur, depth = [], "", 0
450+
def _split_top_level_commas(s: str, quote_aware: bool = False) -> list[str]:
451+
"""Split on commas that are NOT inside ``[...]``/``(...)`` brackets.
452+
453+
With ``quote_aware`` also ignore commas inside SQL string literals
454+
(``'...'`` / ``"..."``) -- needed when splitting SQL expressions to count
455+
aggregate arity (a single-arg ``COUNT(DISTINCT a || ',' || b)`` must not look
456+
multi-column). It is OFF by default because LookML filter VALUES treat an
457+
apostrophe as a literal char (``O'Brien, Smith`` is two values, not one).
458+
"""
459+
out, cur, depth, quote = [], "", 0, None
453460
for ch in s:
461+
if quote_aware and quote is not None:
462+
cur += ch
463+
if ch == quote:
464+
quote = None
465+
continue
466+
if quote_aware and ch in "'\"":
467+
quote = ch
468+
cur += ch
469+
continue
454470
if ch in "[(":
455471
depth += 1
456472
elif ch in ")]":
@@ -978,6 +994,10 @@ def _folded_measure_filter(m_def):
978994
measure_names.add(m_name)
979995
m_type = m.get("type", "count")
980996
agg_template = self._SQL_AGG_FUNC.get(m_type)
997+
# An approximate count_distinct must aggregate approximately when wrapped
998+
# by a post-SQL measure (percent_of_total/previous), matching the direct metric.
999+
if m_type == "count_distinct" and m.get("approximate") in ("yes", True):
1000+
agg_template = "APPROX_COUNT_DISTINCT({0})"
9811001
if agg_template:
9821002
measure_agg_lookup[m_name] = agg_template
9831003
m_sql = m.get("sql")
@@ -1649,6 +1669,10 @@ def _parse_measure(
16491669

16501670
agg_type = type_mapping.get(measure_type)
16511671

1672+
# Looker's `approximate: yes` on a count_distinct -> approximate distinct count.
1673+
if agg_type == "count_distinct" and measure_def.get("approximate") in ("yes", True):
1674+
agg_type = "approx_count_distinct"
1675+
16521676
# Parse filters - lkml parses these as filters__all
16531677
# There are TWO different filter syntaxes in LookML:
16541678
# 1. Shorthand: filters: [status: "completed"]
@@ -2346,6 +2370,135 @@ def export(self, graph: SemanticGraph, output_path: str | Path) -> None:
23462370
lookml_str = lkml.dump(data)
23472371
f.write(lookml_str)
23482372

2373+
@staticmethod
2374+
def _fold_filter_conds(filters: list[str], model: Model) -> str:
2375+
"""Resolve ``metric.filters`` into an AND-joined, ``${TABLE}``-qualified SQL
2376+
predicate for folding into an exported aggregate measure.
2377+
2378+
Field refs are resolved through the model's dimension SQL so a renamed column is
2379+
used, not a bare name. Three forms are handled: ``{model}.col``, the model's own
2380+
name ``orders.col``, and an UNqualified dimension name used as a column
2381+
(``status = 'done'``, matched only before a comparison operator so string VALUES
2382+
aren't rewritten). Each filter is parenthesized so a filter containing ``OR`` is
2383+
not broken by ``AND``'s higher precedence.
2384+
"""
2385+
dim_sql = {d.name: d.sql for d in model.dimensions if d.sql}
2386+
dim_names = {d.name for d in model.dimensions}
2387+
2388+
def _qualify(val: str) -> str:
2389+
# Bare column -> qualify with {model}. so it stays unambiguous in joins;
2390+
# any resolved expression is parenthesized to preserve precedence.
2391+
if re.fullmatch(r"\w+", val):
2392+
return f"({{model}}.{val})"
2393+
return f"({val})"
2394+
2395+
# Qualified ref (group 1), OR a bare known-dimension name (group 2) used as a
2396+
# column anywhere (incl. inside a function like LOWER(status)). Matching is done
2397+
# only OUTSIDE single-quoted string literals (see _resolve), so a quoted value
2398+
# that happens to equal a dimension name is never rewritten. Both alternatives use a
2399+
# negative lookbehind for `.`/word-char: the bare one so it does NOT match the field
2400+
# of a foreign qualifier (`status` inside `customers.status`), and the model-name one
2401+
# so it does NOT match a schema-qualified ref (`orders.status` inside
2402+
# `schema.orders.status`). The bare alt also has a negative lookahead for `(` so it
2403+
# does NOT match a function name (e.g. `date(...)`).
2404+
names_alt = "|".join(re.escape(n) for n in sorted(dim_names, key=len, reverse=True))
2405+
pattern = rf"(?:\{{model\}}|(?<![\w.]){re.escape(model.name)})\.(\w+)"
2406+
if names_alt:
2407+
pattern += rf"|(?<![\w.])({names_alt})\b(?!\s*\()"
2408+
ref_re = re.compile(pattern)
2409+
2410+
def _resolve(fstr: str) -> str:
2411+
def _one(m):
2412+
if m.group(2) is not None:
2413+
# Bare dimension-name alternative: skip when it sits in a SQL TYPE
2414+
# context (a cast target), not a column operand -- e.g. CAST(x AS date)
2415+
# or x::date with a `date` dimension. Rewriting the type token to a
2416+
# column would emit invalid SQL like CAST(x AS (${TABLE}.order_date)).
2417+
pre = m.string[: m.start()]
2418+
if re.search(r"(?is)\bAS\s+$", pre) or pre.rstrip().endswith("::"):
2419+
return m.group(0)
2420+
name = m.group(1) or m.group(2)
2421+
return _qualify(dim_sql.get(name, name))
2422+
2423+
# Split out single-quoted string literals, double-quoted identifiers (handling
2424+
# doubled-quote escapes), backtick (BigQuery/MySQL) and [bracket] (SQL Server)
2425+
# quoted identifiers, AND Liquid/Jinja template segments ({{ ... }} / {% ... %});
2426+
# rewrite refs only in the remaining (even-index) segments so string VALUES, quoted
2427+
# identifiers, and template variables are untouched. The template patterns require
2428+
# DOUBLE braces / brace-percent, so the single-brace {model} placeholder is unaffected.
2429+
parts = re.split(r"""('(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|\[[^\]]*\]|\{\{.*?\}\}|\{%.*?%\})""", fstr)
2430+
for i in range(0, len(parts), 2):
2431+
parts[i] = ref_re.sub(_one, parts[i])
2432+
return "".join(parts)
2433+
2434+
return " AND ".join("(" + _resolve(f).replace("{model}", "${TABLE}") + ")" for f in filters)
2435+
2436+
@classmethod
2437+
def _fold_filters_into_aggregate(cls, agg_sql: str, filters: list[str], model: Model) -> str | None:
2438+
"""Fold ``filters`` into a single-outer-aggregate SQL expression.
2439+
2440+
For ``SUM(${TABLE}.amount)`` + ``status='done'`` returns
2441+
``SUM(CASE WHEN (...) THEN ${TABLE}.amount END)``. Returns ``None`` when the
2442+
expression is not exactly one outer ``FUNC(arg)`` (so the caller can fall back
2443+
rather than mangle a complex expression).
2444+
"""
2445+
m = re.match(r"^\s*(\w+)\s*\((.*)\)\s*$", agg_sql, re.S)
2446+
if not m:
2447+
return None
2448+
func, arg = m.group(1), m.group(2)
2449+
# Confirm the parens wrap the WHOLE expression (no premature close, e.g.
2450+
# "SUM(a)/COUNT(b)" must not be treated as one outer SUM(...)). Quote-aware: a paren
2451+
# inside a string literal / quoted identifier (e.g. CONCAT(a, ')')) is not syntax.
2452+
depth = 0
2453+
quote = None
2454+
for ch in arg:
2455+
if quote is not None:
2456+
if ch == quote:
2457+
quote = None
2458+
continue
2459+
if ch in "'\"`":
2460+
quote = ch
2461+
elif ch == "(":
2462+
depth += 1
2463+
elif ch == ")":
2464+
depth -= 1
2465+
if depth < 0:
2466+
return None
2467+
if depth != 0:
2468+
return None
2469+
arg = arg.strip()
2470+
# The outer FUNC must itself be the aggregate. A scalar wrapper around an aggregate
2471+
# (e.g. ABS(SUM(amount))) has the aggregate in `arg`; folding would push CASE around
2472+
# the inner aggregate (ABS(CASE WHEN ... THEN SUM(amount) END)) -> wrong. Bail so the
2473+
# caller skips rather than emit invalid SQL.
2474+
from sidemantic.sql.aggregation_detection import sql_has_aggregate as _has_agg
2475+
2476+
if _has_agg(arg):
2477+
return None
2478+
conds = cls._fold_filter_conds(filters, model)
2479+
# COUNT(*) -> COUNT(CASE WHEN ... THEN 1 END): "* " can't live inside CASE.
2480+
if arg == "*":
2481+
return f"{func}(CASE WHEN {conds} THEN 1 END)"
2482+
# COUNT(DISTINCT x) -> COUNT(DISTINCT CASE WHEN ... THEN x END): DISTINCT stays
2483+
# outside the CASE (it's part of the aggregate, not the value being filtered). Accept
2484+
# the parenthesized spelling COUNT(DISTINCT(x)) too; the lookahead requires a space or
2485+
# `(` after DISTINCT so an identifier like `DISTINCTION` is not mistaken for it.
2486+
dm = re.match(r"(?is)^DISTINCT(?=[\s(])\s*(.+)$", arg)
2487+
if dm:
2488+
distinct_arg = dm.group(1).strip()
2489+
# A multi-column DISTINCT (COUNT(DISTINCT a, b)) has no single CASE result, so
2490+
# bail and let the caller skip rather than emit malformed `THEN a, b END`.
2491+
# quote_aware: a delimited composite key COUNT(DISTINCT a || ',' || b) is ONE
2492+
# column -- the comma in the string literal must not count as a separator.
2493+
if len(cls._split_top_level_commas(distinct_arg, quote_aware=True)) > 1:
2494+
return None
2495+
return f"{func}(DISTINCT CASE WHEN {conds} THEN {distinct_arg} END)"
2496+
# A multi-argument aggregate (WEIGHTED_AVG(price, qty)) has no single CASE result,
2497+
# so bail rather than emit malformed `THEN price, qty END`.
2498+
if len(cls._split_top_level_commas(arg, quote_aware=True)) > 1:
2499+
return None
2500+
return f"{func}(CASE WHEN {conds} THEN {arg} END)"
2501+
23492502
def _export_view(self, model: Model, graph: SemanticGraph) -> dict:
23502503
"""Export model to LookML view definition.
23512504
@@ -2475,9 +2628,12 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict:
24752628
view["dimension_groups"] = dimension_groups
24762629

24772630
# Export measures
2631+
from sidemantic.sql.aggregation_detection import sql_has_aggregate as _sql_has_aggregate
2632+
24782633
measures = []
24792634
for metric in model.metrics:
24802635
measure_def = {"name": metric.name}
2636+
filters_folded = False # set when filters are folded into the measure SQL
24812637

24822638
# Handle different metric types
24832639
if metric.type == "time_comparison":
@@ -2527,23 +2683,123 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict:
25272683
if metric.numerator and metric.denominator:
25282684
measure_def["sql"] = f"1.0 * ${{{metric.numerator}}} / NULLIF(${{{metric.denominator}}}, 0)"
25292685
else:
2530-
# Regular aggregation measure
2531-
type_mapping = {
2532-
"count": "count",
2533-
"count_distinct": "count_distinct",
2534-
"sum": "sum",
2535-
"avg": "average",
2536-
"min": "min",
2537-
"max": "max",
2538-
}
2539-
measure_def["type"] = type_mapping.get(metric.agg, "count")
2540-
2541-
if metric.sql:
2542-
sql = metric.sql.replace("{model}", "${TABLE}")
2543-
measure_def["sql"] = sql
2686+
# Any metric.type that reaches here (time_comparison/derived/ratio
2687+
# were handled above) is a complex type. A running_total imported from
2688+
# LookML (type=cumulative + table_calculation meta) round-trips back to
2689+
# a LookML running_total over its base measure; other complex types
2690+
# (cumulative/conversion/retention/cohort) have no LookML equivalent and
2691+
# are skipped rather than exported as a misleading plain aggregation.
2692+
if metric.type is not None:
2693+
rt_sql = (metric.sql or "").strip()
2694+
rt_is_running_total = (metric.meta or {}).get("table_calculation") == "running_total" and rt_sql
2695+
# A LookML running_total's `sql` is a SINGLE base-measure reference.
2696+
# Accept a bare measure name (-> ${name}) or a string that is EXACTLY one
2697+
# already-braced ref (an unresolved cross-view ${other.total}, passed
2698+
# through). An EXPRESSION (e.g. "${other.total} + tax" -- note the local
2699+
# ref also lost its braces) is not a valid single ref, so fall through to
2700+
# the skip-with-warning rather than emit malformed `sql: ${other.total} + tax`.
2701+
if rt_is_running_total and re.fullmatch(r"\$\{[^{}]+\}", rt_sql):
2702+
measure_def["type"] = "running_total"
2703+
measure_def["sql"] = rt_sql
2704+
elif rt_is_running_total and re.fullmatch(r"\w+", rt_sql):
2705+
measure_def["type"] = "running_total"
2706+
measure_def["sql"] = f"${{{rt_sql}}}"
2707+
else:
2708+
logger.warning(
2709+
"Metric %r (type=%r) has no LookML equivalent; skipping on export.",
2710+
metric.name,
2711+
metric.type,
2712+
)
2713+
continue
2714+
else:
2715+
# Regular aggregation measure.
2716+
type_mapping = {
2717+
"count": "count",
2718+
"count_distinct": "count_distinct",
2719+
"sum": "sum",
2720+
"avg": "average",
2721+
"average": "average",
2722+
"min": "min",
2723+
"max": "max",
2724+
"median": "median",
2725+
}
2726+
# Aggregations Looker has no native measure type for: emit as a
2727+
# type: number with an explicit SQL aggregate.
2728+
sql_agg_funcs = {
2729+
"stddev": "STDDEV",
2730+
"stddev_pop": "STDDEV_POP",
2731+
"variance": "VAR_SAMP",
2732+
"variance_pop": "VAR_POP",
2733+
}
2734+
col_sql = metric.sql.replace("{model}", "${TABLE}") if metric.sql else None
2735+
2736+
if metric.agg == "approx_count_distinct":
2737+
# Looker represents this as count_distinct with approximate: yes.
2738+
measure_def["type"] = "count_distinct"
2739+
measure_def["approximate"] = "yes"
2740+
if col_sql:
2741+
measure_def["sql"] = col_sql
2742+
elif metric.agg in type_mapping:
2743+
measure_def["type"] = type_mapping[metric.agg]
2744+
if col_sql:
2745+
measure_def["sql"] = col_sql
2746+
elif metric.agg in sql_agg_funcs and col_sql:
2747+
measure_def["type"] = "number"
2748+
inner = col_sql
2749+
if metric.filters:
2750+
# type: number re-imports as a derived metric whose generator
2751+
# does not apply LookML `filters`, so fold them into the
2752+
# aggregate here and skip the separate filters block below.
2753+
conds = self._fold_filter_conds(metric.filters, model)
2754+
inner = f"CASE WHEN {conds} THEN {col_sql} END"
2755+
filters_folded = True
2756+
measure_def["sql"] = f"{sql_agg_funcs[metric.agg]}({inner})"
2757+
elif metric.agg is None and col_sql and _sql_has_aggregate(metric.sql or ""):
2758+
# An agg-less measure whose SQL is itself an aggregate (a complete
2759+
# SUM({model}.amount) imported from Cube, or an inline aggregate
2760+
# expression). Faithfully maps to a LookML type: number with the
2761+
# aggregate SQL. type: number re-imports as a derived metric that
2762+
# does NOT apply a separate `filters` block, so any filters must be
2763+
# folded into the aggregate; if the expression isn't a single
2764+
# foldable FUNC(arg), skip rather than emit a silently-unfiltered
2765+
# measure.
2766+
if not metric.filters and re.fullmatch(r"(?i)count\s*\(\s*(?:\*|\d+)\s*\)", col_sql.strip()):
2767+
# A bare row count -- COUNT(*), COUNT(1), COUNT(0), incl. spaced
2768+
# COUNT (*) -- references
2769+
# no column; a type: number would re-import as a derived metric
2770+
# over an empty CTE (SELECT FROM ...), which the compiler rejects.
2771+
# LookML's native type: count counts rows and round-trips cleanly.
2772+
measure_def["type"] = "count"
2773+
else:
2774+
measure_def["type"] = "number"
2775+
if metric.filters:
2776+
folded = self._fold_filters_into_aggregate(col_sql, metric.filters, model)
2777+
if folded is None:
2778+
logger.warning(
2779+
"Metric %r has filters over a complex aggregate SQL expression that "
2780+
"cannot be folded for LookML export; skipping to avoid an unfiltered measure.",
2781+
metric.name,
2782+
)
2783+
continue
2784+
measure_def["sql"] = folded
2785+
filters_folded = True
2786+
else:
2787+
measure_def["sql"] = col_sql
2788+
else:
2789+
# agg=None over a NON-aggregate SQL (a plain row-level column /
2790+
# string / yesno measure), an unknown aggregation, or an opaque
2791+
# complete *column* expression: Looker measures aggregate, so there
2792+
# is no faithful measure form. Skip with a warning rather than
2793+
# forcing a misleading type: number that crashes on re-import.
2794+
logger.warning(
2795+
"Metric %r (agg=%r) has no LookML equivalent; skipping on export.",
2796+
metric.name,
2797+
metric.agg,
2798+
)
2799+
continue
25442800

2545-
# Add filters (skip for time_comparison as they don't use filters)
2546-
if metric.filters and metric.type != "time_comparison":
2801+
# Add filters (skip for time_comparison; skip when already folded into SQL)
2802+
if metric.filters and metric.type != "time_comparison" and not filters_folded:
25472803
filters_all = []
25482804
for filter_str in metric.filters:
25492805
# Parse SQL-format filters back to LookML format

0 commit comments

Comments
 (0)