@@ -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