From 2d8b83607e9a54f2cf8916f630bc3d7acc2695f1 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 26 Jun 2026 10:54:58 -0700 Subject: [PATCH 1/3] 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. --- sidemantic/adapters/lookml.py | 318 +++++- tests/adapters/lookml/test_edge_cases.py | 1178 ++++++++++++++++++++++ 2 files changed, 1477 insertions(+), 19 deletions(-) diff --git a/sidemantic/adapters/lookml.py b/sidemantic/adapters/lookml.py index 099f0356..5b563ccd 100644 --- a/sidemantic/adapters/lookml.py +++ b/sidemantic/adapters/lookml.py @@ -464,10 +464,26 @@ def _range_sql_negated(bounds, col: str) -> str: return conds[0] if len(conds) == 1 else "(" + " OR ".join(conds) + ")" @staticmethod - def _split_top_level_commas(s: str) -> list[str]: - """Split on commas that are NOT inside ``[...]``/``(...)`` brackets.""" - out, cur, depth = [], "", 0 + def _split_top_level_commas(s: str, quote_aware: bool = False) -> list[str]: + """Split on commas that are NOT inside ``[...]``/``(...)`` brackets. + + With ``quote_aware`` also ignore commas inside SQL string literals + (``'...'`` / ``"..."``) -- needed when splitting SQL expressions to count + aggregate arity (a single-arg ``COUNT(DISTINCT a || ',' || b)`` must not look + multi-column). It is OFF by default because LookML filter VALUES treat an + apostrophe as a literal char (``O'Brien, Smith`` is two values, not one). + """ + out, cur, depth, quote = [], "", 0, None for ch in s: + if quote_aware and quote is not None: + cur += ch + if ch == quote: + quote = None + continue + if quote_aware and ch in "'\"": + quote = ch + cur += ch + continue if ch in "[(": depth += 1 elif ch in ")]": @@ -1057,6 +1073,10 @@ def _folded_measure_filter(m_def): measure_names.add(m_name) m_type = m.get("type", "count") agg_template = self._SQL_AGG_FUNC.get(m_type) + # An approximate count_distinct must aggregate approximately when wrapped + # by a post-SQL measure (percent_of_total/previous), matching the direct metric. + if m_type == "count_distinct" and m.get("approximate") in ("yes", True): + agg_template = "APPROX_COUNT_DISTINCT({0})" if agg_template: measure_agg_lookup[m_name] = agg_template m_sql = m.get("sql") @@ -1810,6 +1830,10 @@ def _parse_measure( agg_type = type_mapping.get(measure_type) + # Looker's `approximate: yes` on a count_distinct -> approximate distinct count. + if agg_type == "count_distinct" and measure_def.get("approximate") in ("yes", True): + agg_type = "approx_count_distinct" + # Parse filters - lkml parses these as filters__all # There are TWO different filter syntaxes in LookML: # 1. Shorthand: filters: [status: "completed"] @@ -2529,6 +2553,147 @@ def export(self, graph: SemanticGraph, output_path: str | Path) -> None: lookml_str = lkml.dump(data) f.write(lookml_str) + @staticmethod + def _fold_filter_conds(filters: list[str], model: Model) -> str: + """Resolve ``metric.filters`` into an AND-joined, ``${TABLE}``-qualified SQL + predicate for folding into an exported aggregate measure. + + Field refs are resolved through the model's dimension SQL so a renamed column is + used, not a bare name. Three forms are handled: ``{model}.col``, the model's own + name ``orders.col``, and an UNqualified dimension name used as a column + (``status = 'done'``, matched only before a comparison operator so string VALUES + aren't rewritten). Each filter is parenthesized so a filter containing ``OR`` is + not broken by ``AND``'s higher precedence. + """ + dim_sql = {d.name: d.sql for d in model.dimensions if d.sql} + dim_names = {d.name for d in model.dimensions} + + def _qualify(val: str) -> str: + # Bare column -> qualify with {model}. so it stays unambiguous in joins; + # any resolved expression is parenthesized to preserve precedence. + if re.fullmatch(r"\w+", val): + return f"({{model}}.{val})" + return f"({val})" + + # Qualified ref (group 1), OR a bare known-dimension name (group 2) used as a + # column anywhere (incl. inside a function like LOWER(status)). Matching is done + # only OUTSIDE single-quoted string literals (see _resolve), so a quoted value + # that happens to equal a dimension name is never rewritten. Both alternatives use a + # negative lookbehind for `.`/word-char: the bare one so it does NOT match the field + # of a foreign qualifier (`status` inside `customers.status`), and the model-name one + # so it does NOT match a schema-qualified ref (`orders.status` inside + # `schema.orders.status`). The bare alt also has a negative lookahead for `(` so it + # does NOT match a function name (e.g. `date(...)`). + names_alt = "|".join(re.escape(n) for n in sorted(dim_names, key=len, reverse=True)) + pattern = rf"(?:\{{model\}}|(? str: + def _one(m): + # The bare-dimension alternative (group 2) only exists when names_alt is + # non-empty; with no declared dimensions the pattern has a single group, so + # read group 2 defensively (m.group(2) would raise IndexError otherwise). + bare = m.group(2) if m.re.groups >= 2 else None + if bare is not None: + # Bare dimension-name alternative: skip when it sits in a SQL TYPE context + # (a cast target), not a column operand -- e.g. CAST(x AS date) or x::date + # with a `date` dimension. Rewriting the type token to a column would emit + # invalid SQL like CAST(x AS (${TABLE}.order_date)). (Typed literals like + # `date '2024-01-01'` are protected earlier, in the split below.) + pre = m.string[: m.start()] + if re.search(r"(?is)\bAS\s+$", pre) or pre.rstrip().endswith("::"): + return m.group(0) + name = m.group(1) or bare + return _qualify(dim_sql.get(name, name)) + + # Split out (and thus protect from rewriting) SQL TYPED LITERALS whose type keyword + # equals a dimension name (`date '2024-01-01'`, `timestamp '...'`, `interval '...'`) + # -- the whole ` '...'` unit is kept intact so the leading `date`/`time`/etc. + # is not mistaken for a column; then single-quoted string literals, double-quoted + # identifiers (doubled-quote escapes), backtick (BigQuery/MySQL) and [bracket] (SQL + # Server) quoted identifiers, AND Liquid/Jinja template segments ({{ }} / {% %}). + # Rewrite refs only in the remaining (even-index) segments so string VALUES, quoted + # identifiers, typed literals, and template variables are untouched. The template + # patterns require DOUBLE braces / brace-percent, so the single-brace {model} is safe. + parts = re.split( + r"""((?i:\b(?:date|time|timestamp|timestamptz|datetime|interval)\s+'(?:[^']|'')*')""" + r"""|'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|\[[^\]]*\]|\{\{.*?\}\}|\{%.*?%\})""", + fstr, + ) + for i in range(0, len(parts), 2): + parts[i] = ref_re.sub(_one, parts[i]) + return "".join(parts) + + return " AND ".join("(" + _resolve(f).replace("{model}", "${TABLE}") + ")" for f in filters) + + @classmethod + def _fold_filters_into_aggregate(cls, agg_sql: str, filters: list[str], model: Model) -> str | None: + """Fold ``filters`` into a single-outer-aggregate SQL expression. + + For ``SUM(${TABLE}.amount)`` + ``status='done'`` returns + ``SUM(CASE WHEN (...) THEN ${TABLE}.amount END)``. Returns ``None`` when the + expression is not exactly one outer ``FUNC(arg)`` (so the caller can fall back + rather than mangle a complex expression). + """ + m = re.match(r"^\s*(\w+)\s*\((.*)\)\s*$", agg_sql, re.S) + if not m: + return None + func, arg = m.group(1), m.group(2) + # Confirm the parens wrap the WHOLE expression (no premature close, e.g. + # "SUM(a)/COUNT(b)" must not be treated as one outer SUM(...)). Quote-aware: a paren + # inside a string literal / quoted identifier (e.g. CONCAT(a, ')')) is not syntax. + depth = 0 + quote = None + for ch in arg: + if quote is not None: + if ch == quote: + quote = None + continue + if ch in "'\"`": + quote = ch + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth < 0: + return None + if depth != 0: + return None + arg = arg.strip() + # The outer FUNC must itself be the aggregate. A scalar wrapper around an aggregate + # (e.g. ABS(SUM(amount))) has the aggregate in `arg`; folding would push CASE around + # the inner aggregate (ABS(CASE WHEN ... THEN SUM(amount) END)) -> wrong. Bail so the + # caller skips rather than emit invalid SQL. + from sidemantic.sql.aggregation_detection import sql_has_aggregate as _has_agg + + if _has_agg(arg): + return None + conds = cls._fold_filter_conds(filters, model) + # COUNT(*) -> COUNT(CASE WHEN ... THEN 1 END): "* " can't live inside CASE. + if arg == "*": + return f"{func}(CASE WHEN {conds} THEN 1 END)" + # COUNT(DISTINCT x) -> COUNT(DISTINCT CASE WHEN ... THEN x END): DISTINCT stays + # outside the CASE (it's part of the aggregate, not the value being filtered). Accept + # the parenthesized spelling COUNT(DISTINCT(x)) too; the lookahead requires a space or + # `(` after DISTINCT so an identifier like `DISTINCTION` is not mistaken for it. + dm = re.match(r"(?is)^DISTINCT(?=[\s(])\s*(.+)$", arg) + if dm: + distinct_arg = dm.group(1).strip() + # A multi-column DISTINCT (COUNT(DISTINCT a, b)) has no single CASE result, so + # bail and let the caller skip rather than emit malformed `THEN a, b END`. + # quote_aware: a delimited composite key COUNT(DISTINCT a || ',' || b) is ONE + # column -- the comma in the string literal must not count as a separator. + if len(cls._split_top_level_commas(distinct_arg, quote_aware=True)) > 1: + return None + return f"{func}(DISTINCT CASE WHEN {conds} THEN {distinct_arg} END)" + # A multi-argument aggregate (WEIGHTED_AVG(price, qty)) has no single CASE result, + # so bail rather than emit malformed `THEN price, qty END`. + if len(cls._split_top_level_commas(arg, quote_aware=True)) > 1: + return None + return f"{func}(CASE WHEN {conds} THEN {arg} END)" + def _export_view(self, model: Model, graph: SemanticGraph) -> dict: """Export model to LookML view definition. @@ -2658,9 +2823,12 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict: view["dimension_groups"] = dimension_groups # Export measures + from sidemantic.sql.aggregation_detection import sql_has_aggregate as _sql_has_aggregate + measures = [] for metric in model.metrics: measure_def = {"name": metric.name} + filters_folded = False # set when filters are folded into the measure SQL # Handle different metric types if metric.type == "time_comparison": @@ -2710,23 +2878,135 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict: if metric.numerator and metric.denominator: measure_def["sql"] = f"1.0 * ${{{metric.numerator}}} / NULLIF(${{{metric.denominator}}}, 0)" else: - # Regular aggregation measure - type_mapping = { - "count": "count", - "count_distinct": "count_distinct", - "sum": "sum", - "avg": "average", - "min": "min", - "max": "max", - } - measure_def["type"] = type_mapping.get(metric.agg, "count") - - if metric.sql: - sql = metric.sql.replace("{model}", "${TABLE}") - measure_def["sql"] = sql + # Any metric.type that reaches here (time_comparison/derived/ratio + # were handled above) is a complex type. A running_total imported from + # LookML (type=cumulative + table_calculation meta) round-trips back to + # a LookML running_total over its base measure; other complex types + # (cumulative/conversion/retention/cohort) have no LookML equivalent and + # are skipped rather than exported as a misleading plain aggregation. + if metric.type is not None: + rt_sql = (metric.sql or "").strip() + rt_is_running_total = (metric.meta or {}).get("table_calculation") == "running_total" and rt_sql + # A LookML running_total's `sql` is a SINGLE base-measure reference. + # Accept a bare measure name (-> ${name}) or a string that is EXACTLY one + # already-braced ref (an unresolved cross-view ${other.total}, passed + # through). An EXPRESSION (e.g. "${other.total} + tax" -- note the local + # ref also lost its braces) is not a valid single ref, so fall through to + # the skip-with-warning rather than emit malformed `sql: ${other.total} + tax`. + if rt_is_running_total and re.fullmatch(r"\$\{[^{}]+\}", rt_sql): + measure_def["type"] = "running_total" + measure_def["sql"] = rt_sql + elif rt_is_running_total and re.fullmatch(r"\w+", rt_sql): + measure_def["type"] = "running_total" + measure_def["sql"] = f"${{{rt_sql}}}" + else: + logger.warning( + "Metric %r (type=%r) has no LookML equivalent; skipping on export.", + metric.name, + metric.type, + ) + continue + else: + # Regular aggregation measure. + type_mapping = { + "count": "count", + "count_distinct": "count_distinct", + "sum": "sum", + "avg": "average", + "average": "average", + "min": "min", + "max": "max", + "median": "median", + } + # Aggregations Looker has no native measure type for: emit as a + # type: number with an explicit SQL aggregate. + sql_agg_funcs = { + "stddev": "STDDEV", + "stddev_pop": "STDDEV_POP", + "variance": "VAR_SAMP", + "variance_pop": "VAR_POP", + } + col_sql = metric.sql.replace("{model}", "${TABLE}") if metric.sql else None + + if metric.agg == "approx_count_distinct": + # Looker represents this as count_distinct with approximate: yes. + measure_def["type"] = "count_distinct" + measure_def["approximate"] = "yes" + if col_sql: + measure_def["sql"] = col_sql + elif metric.agg in type_mapping: + measure_def["type"] = type_mapping[metric.agg] + if col_sql: + measure_def["sql"] = col_sql + elif metric.agg in sql_agg_funcs and col_sql: + measure_def["type"] = "number" + agg_sql = f"{sql_agg_funcs[metric.agg]}({col_sql})" + if metric.filters: + # type: number re-imports as a derived metric whose generator + # does not apply LookML `filters`, so fold them into the aggregate + # here. Reuse _fold_filters_into_aggregate so DISTINCT stays OUTSIDE + # the CASE (STDDEV(DISTINCT amount) -> STDDEV(DISTINCT CASE WHEN ... + # THEN amount END), not STDDEV(CASE WHEN ... THEN DISTINCT amount END)); + # skip if the form can't be folded rather than emit invalid SQL. + folded = self._fold_filters_into_aggregate(agg_sql, metric.filters, model) + if folded is None: + logger.warning( + "Metric %r (agg=%r) has filters over an aggregate form that " + "cannot be folded for LookML export; skipping to avoid invalid SQL.", + metric.name, + metric.agg, + ) + continue + measure_def["sql"] = folded + filters_folded = True + else: + measure_def["sql"] = agg_sql + elif metric.agg is None and col_sql and _sql_has_aggregate(metric.sql or ""): + # An agg-less measure whose SQL is itself an aggregate (a complete + # SUM({model}.amount) imported from Cube, or an inline aggregate + # expression). Faithfully maps to a LookML type: number with the + # aggregate SQL. type: number re-imports as a derived metric that + # does NOT apply a separate `filters` block, so any filters must be + # folded into the aggregate; if the expression isn't a single + # foldable FUNC(arg), skip rather than emit a silently-unfiltered + # measure. + if not metric.filters and re.fullmatch(r"(?i)count\s*\(\s*(?:\*|\d+)\s*\)", col_sql.strip()): + # A bare row count -- COUNT(*), COUNT(1), COUNT(0), incl. spaced + # COUNT (*) -- references + # no column; a type: number would re-import as a derived metric + # over an empty CTE (SELECT FROM ...), which the compiler rejects. + # LookML's native type: count counts rows and round-trips cleanly. + measure_def["type"] = "count" + else: + measure_def["type"] = "number" + if metric.filters: + folded = self._fold_filters_into_aggregate(col_sql, metric.filters, model) + if folded is None: + logger.warning( + "Metric %r has filters over a complex aggregate SQL expression that " + "cannot be folded for LookML export; skipping to avoid an unfiltered measure.", + metric.name, + ) + continue + measure_def["sql"] = folded + filters_folded = True + else: + measure_def["sql"] = col_sql + else: + # agg=None over a NON-aggregate SQL (a plain row-level column / + # string / yesno measure), an unknown aggregation, or an opaque + # complete *column* expression: Looker measures aggregate, so there + # is no faithful measure form. Skip with a warning rather than + # forcing a misleading type: number that crashes on re-import. + logger.warning( + "Metric %r (agg=%r) has no LookML equivalent; skipping on export.", + metric.name, + metric.agg, + ) + continue - # Add filters (skip for time_comparison as they don't use filters) - if metric.filters and metric.type != "time_comparison": + # Add filters (skip for time_comparison; skip when already folded into SQL) + if metric.filters and metric.type != "time_comparison" and not filters_folded: filters_all = [] for filter_str in metric.filters: # Parse SQL-format filters back to LookML format diff --git a/tests/adapters/lookml/test_edge_cases.py b/tests/adapters/lookml/test_edge_cases.py index aa2f25bf..7a4b58ac 100644 --- a/tests/adapters/lookml/test_edge_cases.py +++ b/tests/adapters/lookml/test_edge_cases.py @@ -3145,5 +3145,1183 @@ def test_lookml_self_referential_dimension_terminates(): assert selfd.sql.count("+ 1") <= 2 +def test_lookml_export_unmapped_aggregations_not_count(): + """Unmapped aggregations / complex types must not be silently exported as COUNT.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + model = Model( + name="orders", + table="orders", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[ + Metric(name="med", agg="median", sql="amount"), + Metric(name="sd", agg="stddev", sql="amount"), + Metric(name="va", agg="variance", sql="amount"), + Metric(name="cnt", agg="count"), + Metric(name="sm", agg="sum", sql="amount"), + Metric(name="cum", type="cumulative", sql="amount"), + ], + ) + graph = SemanticGraph() + graph.add_model(model) + + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + + # median has a native Looker type + assert "type: median" in text + # stddev/variance become type: number with an explicit SQL aggregate + assert "STDDEV(" in text + assert "VAR_SAMP(" in text + # the complex cumulative metric is skipped (no LookML equivalent), not COUNT + assert "measure: cum" not in text + # genuine count/sum unchanged + assert "type: count" in text + assert "type: sum" in text + + # Round-trip: median survives, none of these come back as a plain count corruption + reimported = {m.name: m for m in LookMLAdapter().parse(Path(out)).get_model("orders").metrics} + assert reimported["med"].agg == "median" + assert "cum" not in reimported # skipped on export + + +def test_lookml_export_complex_type_with_agg_skipped(): + """A complex-type metric carrying an agg (e.g. cumulative rolling avg) must be skipped, not exported as a plain measure.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="o", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="roll", type="cumulative", agg="avg", sql="amount")], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: roll" not in text + assert "type: average" not in text # not silently downgraded to a plain average + + +def test_lookml_export_filtered_distinct_stddev_keeps_distinct_outside_case(): + """A filtered DISTINCT stddev/variance must keep DISTINCT OUTSIDE the folded CASE.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + Dimension(name="amount", type="numeric", sql="amount"), + ], + metrics=[ + Metric(name="sd", agg="stddev", sql="DISTINCT {model}.amount", filters=["{model}.status = 'done'"]) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: sd" in text + assert "STDDEV(DISTINCT CASE WHEN" in text # DISTINCT outside the CASE + assert "THEN DISTINCT" not in text # never DISTINCT inside the CASE (invalid SQL) + + +def test_lookml_export_filtered_sql_aggregate_folds_filter(): + """A filtered stddev/variance (type: number path) must fold the filter into SQL, not drop it.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="o", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["{model}.status = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + # The single measure folds its filter into the SQL aggregate (the model-qualified + # ref is resolved to a ${TABLE}-qualified column)... + assert "STDDEV(CASE WHEN" in text + assert "(${TABLE}.status) = 'done'" in text + # ...and is not also emitted as a (non-applied) LookML filters block. + assert "filters" not in text + + +def test_lookml_approximate_distinct_preserved_in_post_sql_measure(): + """A post-SQL measure (percent_of_total) over an approximate count_distinct must stay approximate.""" + graph = _parse_lkml( + """ +view: v { + sql_table_name: t ;; + dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; } + measure: uu { type: count_distinct sql: ${TABLE}.user_id ;; approximate: yes } + measure: pct { type: percent_of_total sql: ${uu} ;; } +} +""" + ) + pct = graph.get_model("v").get_metric("pct") + assert "APPROX_COUNT_DISTINCT" in pct.sql + assert "COUNT(DISTINCT" not in pct.sql + + +def test_lookml_export_running_total_roundtrips(): + """An imported running_total (cumulative + table_calculation meta) round-trips, not dropped.""" + import tempfile + + graph = _parse_lkml( + """ +view: v { + sql_table_name: t ;; + dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; } + measure: total { type: sum sql: ${TABLE}.amt ;; } + measure: rt { type: running_total sql: ${total} ;; } +} +""" + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + assert "type: running_total" in open(out).read() + reimported = {m.name: m for m in LookMLAdapter().parse(Path(out)).get_model("v").metrics} + assert "rt" in reimported + assert (reimported["rt"].meta or {}).get("table_calculation") == "running_total" + + +def test_lookml_export_approximate_distinct_preserved(): + """approx_count_distinct must export as count_distinct + approximate: yes and round-trip.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="o", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="uu", agg="approx_count_distinct", sql="user_id")], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "type: count_distinct" in text + assert "approximate: yes" in text + reimported = {m.name: m for m in LookMLAdapter().parse(Path(out)).get_model("o").metrics} + assert reimported["uu"].agg == "approx_count_distinct" + + +def test_lookml_export_opaque_complete_sql_measure_skipped(): + """An agg-less sql_is_complete measure has no faithful LookML form and is skipped, not exported as broken derived.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="o", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="opaque", agg=None, sql="status_label", sql_is_complete=True)], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + assert "measure: opaque" not in open(out).read() + + +def test_lookml_export_folded_filter_resolves_dimension_sql(): + """A folded filter must reference the dimension's SQL column, not the bare dimension name.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="o", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["{model}.status = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + # Resolved dimension column is qualified (${TABLE}.) and parenthesized. + assert "(${TABLE}.order_status) = 'done'" in open(out).read() + + +def test_lookml_export_folded_filter_resolves_model_qualified_ref(): + """A folded filter qualified by the model's own NAME (orders.status) resolves too.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["orders.status = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "(${TABLE}.order_status) = 'done'" in text + assert "orders.status" not in text # model-name prefix was normalized away + + +def test_lookml_export_complete_aggregate_sql_measure_not_dropped(): + """An opaque COMPLETE aggregate measure (e.g. from Cube) exports as type: number, not dropped.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="t", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="total_amt", agg=None, sql="SUM({model}.amount)", sql_is_complete=True)], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: total_amt" in text # not dropped + assert "type: number" in text + assert "SUM(${TABLE}.amount)" in text + + +def test_lookml_export_string_measure_not_forced_to_number(): + """A non-aggregate (string/yesno/row-level) agg-less measure must NOT export as number. + + Looker measures aggregate; forcing a raw column measure to type: number re-imports as + a derived metric and crashes ({model}.status read as a metric dep), so it is skipped. + """ + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="t", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="label", agg=None, sql="{model}.status")], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + assert "measure: label" not in open(out).read() # skipped, not exported as type: number + + +def test_lookml_export_complete_aggregate_with_filters_folds_into_aggregate(): + """A complete aggregate measure WITH filters folds them into the aggregate (not a dropped filter).""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + metrics=[ + Metric( + name="done_amt", + agg=None, + sql="SUM({model}.amount)", + sql_is_complete=True, + filters=["{model}.status = 'done'"], + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + # Filter folded INSIDE the aggregate, and no separate (ignored-on-reimport) filters block. + assert "SUM(CASE WHEN" in text + assert "(${TABLE}.order_status) = 'done'" in text + measure_block = text[text.index("measure: done_amt") :] + measure_block = measure_block[: measure_block.index("}")] + assert "filters:" not in measure_block + + +def test_lookml_export_folded_filter_resolves_compact_dimension(): + """A folded filter over a COMPACT (no-sql) dimension resolves to ${TABLE}.col, not .col.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id"), Dimension(name="status", type="categorical")], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["orders.status = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "(${TABLE}.status) = 'done'" in text # default column, qualified to ${TABLE} + assert "orders.status" not in text # not left pointing at a literal `orders` table + + +def test_lookml_export_folded_filter_resolves_unqualified_dimension(): + """An UNqualified folded filter field (status = 'done') resolves through dimension SQL.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["status = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "(${TABLE}.order_status) = 'done'" in text # unqualified dim -> its real column + # 'done' (a string VALUE) must NOT be rewritten even though it's not a dimension. + assert "'done'" in text + + +def test_lookml_export_folded_filter_resolves_dimension_inside_function(): + """An unqualified dimension INSIDE a function (LOWER(status)) resolves; quoted value is safe.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + # LOWER(status): dim inside a function; and a quoted value equal to a dim name. + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["LOWER(status) = 'status'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "LOWER((${TABLE}.order_status))" in text # dim resolved inside the function + assert "= 'status'" in text # the quoted value was NOT rewritten + + +def test_lookml_export_folded_filter_leaves_quoted_identifier_untouched(): + """A double-quoted identifier in a folded filter must not be substituted inside.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["\"status\" = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "\"status\" = 'done'" in text # quoted identifier passes through verbatim + assert '"(${TABLE}' not in text # NOT substituted inside the quotes (invalid SQL) + + +def test_lookml_export_folded_filter_leaves_foreign_qualified_field_untouched(): + """A foreign-qualified field (customers.status) must not have its `status` part rewritten.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["customers.status = 'vip'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "customers.status = 'vip'" in text # foreign qualifier left intact + assert "customers.(" not in text # the `status` part is NOT rewritten into a malformed ref + + +def test_lookml_export_folded_filter_does_not_rewrite_function_name(): + """A folded filter's SQL function name equal to a dimension name must not be rewritten.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="date", type="time", granularity="day", sql="order_date"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["date(created_at) = '2024-01-01'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "date(created_at)" in text # function call left intact + assert "order_date)(" not in text # the function name is NOT rewritten to a column + + +def test_lookml_export_folded_filter_does_not_rewrite_template_variable(): + """A folded filter's Liquid/Jinja template variable equal to a dimension name is untouched.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), # dim named 'status' + Dimension(name="amount", type="numeric", sql="amount"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="{model}.amount", filters=["{model}.status = {{ status }}"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: sd" in text + assert "{{ status }}" in text # template variable left intact + assert "{{ (${TABLE}" not in text # NOT rewritten inside the template + assert "order_status" in text # the real column operand IS resolved + + +def test_lookml_export_folded_filter_no_dimensions_does_not_crash(): + """Folding a qualified filter on a model with NO dimensions must not IndexError. + + With no declared dimensions the bare-name alternative is absent from the regex (one group), + so the callback must read group 2 defensively instead of raising 'no such group'. + """ + import tempfile + + from sidemantic import Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[], # no dimensions -> names_alt empty -> single-group regex + metrics=[Metric(name="sd", agg="stddev", sql="{model}.amount", filters=["{model}.status = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) # must not raise IndexError + text = open(out).read() + assert "measure: sd" in text + assert "${TABLE}.status" in text # qualified filter still folded + + +def test_lookml_export_folded_filter_does_not_rewrite_typed_date_literal(): + """A folded filter's typed date literal (`date '...'`) must not be rewritten as a column.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="date", type="time", granularity="day", sql="order_date"), # dim named 'date' + Dimension(name="amount", type="numeric", sql="amount"), + ], + metrics=[ + Metric(name="sd", agg="stddev", sql="{model}.amount", filters=["created_at >= date '2024-01-01'"]) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: sd" in text + assert "date '2024-01-01'" in text # typed literal left intact + assert "order_date) '2024" not in text # NOT rewritten into a column + + +def test_lookml_export_folded_filter_does_not_rewrite_cast_type(): + """A folded filter's SQL CAST type token equal to a dimension name must not be rewritten.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="date", type="time", granularity="day", sql="order_date"), # dim named 'date' + Dimension(name="amount", type="numeric", sql="amount"), + ], + metrics=[ + Metric( + name="sd", agg="stddev", sql="{model}.amount", filters=["CAST(created_at AS date) = '2024-01-01'"] + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: sd" in text # not skipped + assert "CAST(created_at AS date)" in text # type token intact + assert "AS (${TABLE}" not in text # the cast type is NOT rewritten to a column + + +def test_lookml_export_scalar_wrapped_aggregate_filter_skipped(): + """A scalar-wrapped aggregate with filters (ABS(SUM(x))) can't fold -> skipped, not mangled.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + ], + metrics=[ + Metric( + name="aw", + agg=None, + sql="ABS(SUM({model}.amount))", + sql_is_complete=True, + filters=["{model}.status = 'x'"], + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: aw" not in text # can't fold CASE around the inner aggregate -> skipped + assert "ABS(CASE WHEN" not in text # never push CASE around a nested aggregate + + +def test_lookml_export_multi_column_distinct_filter_skipped(): + """A multi-column COUNT(DISTINCT a, b) with filters can't fold to one CASE -> skipped, not malformed.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + ], + metrics=[ + Metric( + name="du", + agg=None, + sql="COUNT(DISTINCT {model}.a, {model}.b)", + sql_is_complete=True, + filters=["{model}.status = 'x'"], + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + assert "measure: du" not in open(out).read() # skipped, no malformed `THEN a, b END` + + +def test_lookml_export_folded_filter_leaves_backtick_identifier_untouched(): + """A folded filter's backtick/bracket-quoted identifier must not be rewritten inside the quotes.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), # dim named 'status' + Dimension(name="amount", type="numeric", sql="amount"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="{model}.amount", filters=["`status` = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: sd" in text + assert "`status`" in text # backtick identifier intact + assert "`(${TABLE}" not in text # not rewritten inside the backticks + + +def test_lookml_export_string_literal_paren_in_aggregate_arg_folds(): + """A valid aggregate whose arg has a paren inside a STRING LITERAL must fold, not be rejected.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + ], + metrics=[ + Metric( + name="du", + agg=None, + sql="COUNT(DISTINCT CONCAT({model}.a, ')'))", # literal ')' must not break depth scan + sql_is_complete=True, + filters=["{model}.status = 'x'"], + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: du" in text # folded, not skipped + assert "DISTINCT CASE WHEN" in text + + +def test_lookml_export_parenthesized_distinct_filter_folds(): + """COUNT(DISTINCT(x)) (parenthesized, no space) must fold its filter, not emit malformed SQL.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + ], + metrics=[ + Metric( + name="du", + agg=None, + sql="COUNT(DISTINCT({model}.uid))", + sql_is_complete=True, + filters=["{model}.status = 'x'"], + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: du" in text + assert "DISTINCT CASE WHEN" in text # folded as DISTINCT over a CASE + assert "THEN DISTINCT(" not in text # NOT the malformed generic-wrapper output + + +def test_lookml_export_delimited_distinct_filter_folds_not_skipped(): + """A single-arg DISTINCT containing a comma STRING LITERAL must fold, not be mis-rejected. + + COUNT(DISTINCT a || ',' || b) is one column; the arity check must ignore the comma + inside the string literal (quote-aware split) instead of treating it as multi-column. + """ + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + ], + metrics=[ + Metric( + name="composite_distinct", + agg=None, + sql="COUNT(DISTINCT {model}.a || ',' || {model}.b)", + sql_is_complete=True, + filters=["{model}.status = 'x'"], + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + txt = open(out).read() + assert "measure: composite_distinct" in txt # folded, NOT skipped + assert "DISTINCT CASE WHEN" in txt # filter folded inside the single-column DISTINCT + + +def test_lookml_export_multi_arg_aggregate_filter_skipped(): + """A multi-argument aggregate WEIGHTED_AVG(price, qty) with filters skips, not malformed CASE.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="status"), + ], + metrics=[ + Metric( + name="wavg", + agg=None, + sql="WEIGHTED_AVG({model}.price, {model}.qty)", + sql_is_complete=True, + filters=["{model}.status = 'x'"], + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + assert "measure: wavg" not in open(out).read() # skipped, no malformed `THEN price, qty END` + + +def test_lookml_export_count_constant_uses_native_count_type(): + """COUNT(1) / COUNT(0) row-count aggregates export as native type: count, like COUNT(*).""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + for expr in ("COUNT(1)", "COUNT(0)"): + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="c", agg=None, sql=expr, sql_is_complete=True)], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "type: count" in text and expr not in text + + +def test_lookml_export_spaced_count_star_maps_to_native_count(): + """A spaced `COUNT (*)` complete aggregate must still export as native type: count.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="c", agg=None, sql="COUNT (*)", sql_is_complete=True)], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + import re + + block = re.search(r"measure: c \{.*?\}", open(out).read(), re.S) + assert block and "type: count" in block.group(0) # native count, not a number over empty CTE + + +def test_lookml_export_count_star_and_distinct_filters_fold_validly(): + """COUNT(*) / COUNT(DISTINCT x) complete aggregates with filters fold to valid SQL that runs.""" + import tempfile + + import duckdb + + from sidemantic import Dimension, Metric, Model, SemanticLayer + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + ], + metrics=[ + Metric(name="cnt", agg=None, sql="COUNT(*)", sql_is_complete=True, filters=["{model}.status = 'done'"]), + Metric( + name="du", + agg=None, + sql="COUNT(DISTINCT {model}.user_id)", + sql_is_complete=True, + filters=["{model}.status = 'done'"], + ), + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "COUNT(CASE WHEN" in text and "THEN 1 END)" in text # COUNT(*) -> THEN 1 + assert "COUNT(DISTINCT CASE WHEN" in text # DISTINCT stays outside the CASE + + layer = SemanticLayer(auto_register=False) + for m in LookMLAdapter().parse(Path(out)).models.values(): + layer.add_model(m) + con = duckdb.connect() + con.execute("create table orders(id int, user_id int, order_status text)") + con.execute("insert into orders values (1,7,'done'),(2,7,'open'),(3,8,'done')") + assert con.execute(layer.compile(metrics=["orders.cnt"])).fetchall() == [(2,)] + assert con.execute(layer.compile(metrics=["orders.du"])).fetchall() == [(2,)] + + +def test_lookml_export_bare_count_star_uses_native_count_type(): + """A bare COUNT(*) complete aggregate exports as native type: count (round-trips + runs). + + type: number would re-import as a derived metric over an empty CTE (SELECT FROM ...), + which the compiler rejects; native type: count counts rows and round-trips cleanly. + """ + import tempfile + + import duckdb + + from sidemantic import Dimension, Metric, Model, SemanticLayer + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="orders", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[Metric(name="cnt", agg=None, sql="COUNT(*)", sql_is_complete=True)], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + assert "type: count" in open(out).read() + reimported = LookMLAdapter().parse(Path(out)) + assert reimported.get_model("orders").get_metric("cnt").agg == "count" + layer = SemanticLayer(auto_register=False) + for m in reimported.models.values(): + layer.add_model(m) + con = duckdb.connect() + con.execute("create table orders as select 1 id union all select 2") + assert con.execute(layer.compile(metrics=["orders.cnt"])).fetchall() == [(2,)] + + +def test_lookml_export_running_total_cross_view_ref_not_double_wrapped(): + """A running_total over an unsupported (already-braced) cross-view ref must not emit ${${...}}.""" + import tempfile + + graph = _parse_lkml( + """ +view: orders { + sql_table_name: t ;; + dimension: id { primary_key: yes type: number sql: ${TABLE}.id ;; } + measure: rt { type: running_total sql: ${other_view.total} ;; } +} +""" + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "${${" not in text + assert "sql: ${other_view.total}" in text + + +def test_lookml_export_running_total_braced_ref_plus_expression_skipped(): + """A running_total whose sql is a braced cross-view ref PLUS more must be skipped, not exported. + + `${other.total} + tax` contains `${` so a substring check would wrongly accept it and emit + a malformed `sql: ${other.total} + tax` (the local `tax` ref already lost its braces). Only + a string that is EXACTLY one `${...}` reference may pass through. + """ + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[ + Metric( + name="rt", + type="cumulative", + sql="${other.total} + tax", + meta={"table_calculation": "running_total"}, + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: rt" not in text # not a single ref -> skipped + assert "${other.total} + tax" not in text # never emit the malformed mixed expression + + +def test_lookml_export_folded_filter_does_not_rewrite_schema_qualified_ref(): + """A folded filter's schema-qualified own-model ref must not match the model-name suffix.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="status", type="categorical", sql="order_status"), + Dimension(name="amount", type="numeric", sql="amount"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="{model}.amount", filters=["schema.orders.status = 'done'"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: sd" in text + assert "schema.orders.status" in text # schema-qualified ref left intact + assert "schema.(${TABLE}" not in text # not mangled into a column substitution + + +def test_lookml_export_running_total_expression_skipped(): + """A running_total over an EXPRESSION (not a single base measure ref) is skipped, not malformed.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="t", + primary_key="id", + dimensions=[Dimension(name="id", type="numeric", sql="id")], + metrics=[ + Metric(name="total", agg="sum", sql="{model}.amt"), + Metric(name="rt", type="cumulative", sql="total + tax", meta={"table_calculation": "running_total"}), + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + assert "measure: rt" not in text # expression isn't a valid running_total base -> skipped + assert "${total + tax}" not in text # never emit a malformed field reference + + +def test_lookml_export_multiple_folded_filters_parenthesized(): + """Each folded filter is parenthesized so a filter containing OR isn't broken by AND precedence.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="o", + primary_key="id", + dimensions=[Dimension(name=n, type="numeric", sql=n) for n in ("id", "a", "b", "c")], + metrics=[ + Metric( + name="sd", agg="stddev", sql="amount", filters=["{model}.a = 1 OR {model}.b = 1", "{model}.c = 1"] + ) + ], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + text = open(out).read() + # The OR filter is grouped before being AND-joined with the second filter. + assert "((${TABLE}.a) = 1 OR (${TABLE}.b) = 1) AND ((${TABLE}.c) = 1)" in text + + +def test_lookml_export_folded_filter_parenthesizes_expression_dimension(): + """A folded filter on a dimension whose SQL is an expression must be parenthesized.""" + import tempfile + + from sidemantic import Dimension, Metric, Model + from sidemantic.core.semantic_graph import SemanticGraph + + graph = SemanticGraph() + graph.add_model( + Model( + name="o", + table="o", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="eligible", type="categorical", sql="{model}.amount > 10 OR {model}.special"), + ], + metrics=[Metric(name="sd", agg="stddev", sql="amount", filters=["{model}.eligible = false"])], + ) + ) + out = tempfile.mktemp(suffix=".lkml") + LookMLAdapter().export(graph, out) + assert "(${TABLE}.amount > 10 OR ${TABLE}.special) = false" in open(out).read() + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 6805a9d2cc7bcdefce270b782176dfc863fe0423 Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 10 Jul 2026 09:21:10 -0700 Subject: [PATCH 2/3] Handle constant-count exports and protect date-part keywords in folded filters Two LookML export bugs producing invalid re-import SQL: - COUNT over a non-null constant other than *//digit (COUNT(TRUE), COUNT('x'), COUNT(1.0)) fell through to type: number and re-imported as a zero-column complete-SQL metric, whose query hits an empty model CTE (SELECT FROM ...). Classify every non-null constant COUNT as a native row count (type: count); COUNT(NULL) is excluded since it is always 0. - A folded-filter bare-name resolver rewrote date-part / interval-unit keywords that match a dimension name -- e.g. EXTRACT(day FROM ...) or INTERVAL 7 day on a model with a 'day' dimension -- into the dimension SQL, emitting invalid LookML like EXTRACT((${TABLE}.order_day) FROM ...). Protect the EXTRACT-part, extract-FROM, and INTERVAL-unit positions. Context checks now use absolute offsets into the full predicate so a split literal (INTERVAL '7' day) is handled. --- sidemantic/adapters/lookml.py | 78 ++++++++++++++++-------- tests/adapters/lookml/test_edge_cases.py | 57 +++++++++++++++-- 2 files changed, 107 insertions(+), 28 deletions(-) diff --git a/sidemantic/adapters/lookml.py b/sidemantic/adapters/lookml.py index 5b563ccd..696da80e 100644 --- a/sidemantic/adapters/lookml.py +++ b/sidemantic/adapters/lookml.py @@ -2591,22 +2591,44 @@ def _qualify(val: str) -> str: ref_re = re.compile(pattern) def _resolve(fstr: str) -> str: - def _one(m): - # The bare-dimension alternative (group 2) only exists when names_alt is - # non-empty; with no declared dimensions the pattern has a single group, so - # read group 2 defensively (m.group(2) would raise IndexError otherwise). - bare = m.group(2) if m.re.groups >= 2 else None - if bare is not None: - # Bare dimension-name alternative: skip when it sits in a SQL TYPE context - # (a cast target), not a column operand -- e.g. CAST(x AS date) or x::date - # with a `date` dimension. Rewriting the type token to a column would emit - # invalid SQL like CAST(x AS (${TABLE}.order_date)). (Typed literals like - # `date '2024-01-01'` are protected earlier, in the split below.) - pre = m.string[: m.start()] - if re.search(r"(?is)\bAS\s+$", pre) or pre.rstrip().endswith("::"): - return m.group(0) - name = m.group(1) or bare - return _qualify(dim_sql.get(name, name)) + def _make_one(base: int): + # `base` is this segment's absolute offset into fstr, so context checks can look + # at the FULL predicate -- not just the current split segment. A quoted number + # (`INTERVAL '7' day`) splits `day` into its own segment, so a segment-local `pre` + # would miss the leading INTERVAL and wrongly rewrite the unit keyword. + def _one(m): + # The bare-dimension alternative (group 2) only exists when names_alt is + # non-empty; with no declared dimensions the pattern has a single group, so + # read group 2 defensively (m.group(2) would raise IndexError otherwise). + bare = m.group(2) if m.re.groups >= 2 else None + if bare is not None: + # Bare dimension-name alternative: skip when it sits in a SQL TYPE context + # (a cast target), not a column operand -- e.g. CAST(x AS date) or x::date + # with a `date` dimension. Rewriting the type token to a column would emit + # invalid SQL like CAST(x AS (${TABLE}.order_date)). (Typed literals like + # `date '2024-01-01'` are protected earlier, in the split below.) + pre = fstr[: base + m.start()] + if re.search(r"(?is)\bAS\s+$", pre) or pre.rstrip().endswith("::"): + return m.group(0) + # Skip a bare token in a DATE-PART / INTERVAL-UNIT keyword position, not a + # column operand -- e.g. EXTRACT(day FROM ...) or `INTERVAL 7 day` on a + # model with a `day` dimension. Rewriting the keyword to the dimension SQL + # emits invalid SQL like EXTRACT((${TABLE}.order_day) FROM ...). Positions: + # right after EXTRACT(, immediately before an extract's FROM, or as the unit + # following an INTERVAL . (Quoted forms like + # DATE_TRUNC('day', ...) and INTERVAL '7 day' are already protected as + # string literals.) + suf = fstr[base + m.end() :] + if ( + re.search(r"(?i)\bextract\s*\(\s*$", pre) + or re.match(r"(?is)\s+from\b", suf) + or re.search(r"(?i)\binterval\s+(?:[+-]?\d+(?:\.\d+)?|'(?:[^']|'')*')\s*$", pre) + ): + return m.group(0) + name = m.group(1) or bare + return _qualify(dim_sql.get(name, name)) + + return _one # Split out (and thus protect from rewriting) SQL TYPED LITERALS whose type keyword # equals a dimension name (`date '2024-01-01'`, `timestamp '...'`, `interval '...'`) @@ -2622,8 +2644,11 @@ def _one(m): r"""|'(?:[^']|'')*'|"(?:[^"]|"")*"|`[^`]*`|\[[^\]]*\]|\{\{.*?\}\}|\{%.*?%\})""", fstr, ) - for i in range(0, len(parts), 2): - parts[i] = ref_re.sub(_one, parts[i]) + offset = 0 + for i, part in enumerate(parts): + if i % 2 == 0: + parts[i] = ref_re.sub(_make_one(offset), part) + offset += len(part) return "".join(parts) return " AND ".join("(" + _resolve(f).replace("{model}", "${TABLE}") + ")" for f in filters) @@ -2970,12 +2995,17 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict: # folded into the aggregate; if the expression isn't a single # foldable FUNC(arg), skip rather than emit a silently-unfiltered # measure. - if not metric.filters and re.fullmatch(r"(?i)count\s*\(\s*(?:\*|\d+)\s*\)", col_sql.strip()): - # A bare row count -- COUNT(*), COUNT(1), COUNT(0), incl. spaced - # COUNT (*) -- references - # no column; a type: number would re-import as a derived metric - # over an empty CTE (SELECT FROM ...), which the compiler rejects. - # LookML's native type: count counts rows and round-trips cleanly. + # A COUNT over any NON-NULL constant counts every row -- it is a native + # row count, identical to type: count: `*`, an int/decimal (1, 0, 1.0, + # .5), a boolean (TRUE/FALSE), or a string literal ('x'). COUNT(NULL) is + # deliberately excluded -- it is always 0, not a row count. + _count_const = r"\*|[+-]?(?:\d+\.?\d*|\.\d+)|true|false|'(?:[^']|'')*'" + if not metric.filters and re.fullmatch( + rf"(?i)count\s*\(\s*(?:{_count_const})\s*\)", col_sql.strip() + ): + # These reference no column; a type: number would re-import as a + # derived metric over an empty CTE (SELECT FROM ...), which the + # compiler rejects. Native type: count round-trips cleanly. measure_def["type"] = "count" else: measure_def["type"] = "number" diff --git a/tests/adapters/lookml/test_edge_cases.py b/tests/adapters/lookml/test_edge_cases.py index 7a4b58ac..7325d4c5 100644 --- a/tests/adapters/lookml/test_edge_cases.py +++ b/tests/adapters/lookml/test_edge_cases.py @@ -3786,6 +3786,41 @@ def test_lookml_export_folded_filter_does_not_rewrite_cast_type(): assert "AS (${TABLE}" not in text # the cast type is NOT rewritten to a column +def test_lookml_export_folded_filter_does_not_rewrite_date_part_keyword(): + """A folded filter's date-part / interval-unit keyword equal to a dimension must not be rewritten. + + With a `day` dimension, EXTRACT(day FROM ...) and INTERVAL 7 day contain the SQL keyword `day` + in a non-column position. Rewriting it to the dimension SQL emits invalid LookML such as + EXTRACT((${TABLE}.order_day) FROM ...); the keyword must be protected while genuine column uses + (and the extract SOURCE column) are still rewritten. + """ + from sidemantic import Dimension, Model + + model = Model( + name="orders", + table="raw_orders", + primary_key="id", + dimensions=[ + Dimension(name="id", type="numeric", sql="id"), + Dimension(name="day", type="time", granularity="day", sql="order_day"), # dim named 'day' + Dimension(name="created_at", type="time", granularity="day", sql="created_at"), + ], + ) + conds = LookMLAdapter._fold_filter_conds + # EXTRACT part keyword protected; the FROM source column IS rewritten. + assert conds(["EXTRACT(day FROM created_at) = 1"], model) == "(EXTRACT(day FROM (${TABLE}.created_at)) = 1)" + # INTERVAL unit keyword protected -- both bare and quoted-number spellings. + assert conds(["created_at >= CURRENT_DATE - INTERVAL 7 day"], model) == ( + "((${TABLE}.created_at) >= CURRENT_DATE - INTERVAL 7 day)" + ) + assert conds(["created_at >= CURRENT_DATE - INTERVAL '7' day"], model) == ( + "((${TABLE}.created_at) >= CURRENT_DATE - INTERVAL '7' day)" + ) + # A genuine column use of the same name IS still rewritten (not over-protected). + assert conds(["day = '2024-01-01'"], model) == "((${TABLE}.order_day) = '2024-01-01')" + assert conds(["LOWER(day) = 'x'"], model) == "(LOWER((${TABLE}.order_day)) = 'x')" + + def test_lookml_export_scalar_wrapped_aggregate_filter_skipped(): """A scalar-wrapped aggregate with filters (ABS(SUM(x))) can't fold -> skipped, not mangled.""" import tempfile @@ -4027,13 +4062,20 @@ def test_lookml_export_multi_arg_aggregate_filter_skipped(): def test_lookml_export_count_constant_uses_native_count_type(): - """COUNT(1) / COUNT(0) row-count aggregates export as native type: count, like COUNT(*).""" + """COUNT over any NON-NULL constant is a native row count and exports as type: count. + + COUNT(1)/COUNT(0), plus COUNT(TRUE), COUNT('x'), COUNT(1.0), COUNT(.5) all count every row. + Exporting them as type: number would re-import as a zero-column complete-SQL metric whose + query hits an empty model CTE (SELECT FROM ...). COUNT(NULL) is NOT a row count (always 0), so + it must stay a type: number. + """ + import re import tempfile from sidemantic import Dimension, Metric, Model from sidemantic.core.semantic_graph import SemanticGraph - for expr in ("COUNT(1)", "COUNT(0)"): + def export_measure(expr): graph = SemanticGraph() graph.add_model( Model( @@ -4046,8 +4088,15 @@ def test_lookml_export_count_constant_uses_native_count_type(): ) out = tempfile.mktemp(suffix=".lkml") LookMLAdapter().export(graph, out) - text = open(out).read() - assert "type: count" in text and expr not in text + return re.search(r"measure: c \{.*?\n \}", open(out).read(), re.S).group(0) + + for expr in ("COUNT(1)", "COUNT(0)", "COUNT(TRUE)", "COUNT('x')", "COUNT(1.0)", "COUNT(.5)"): + block = export_measure(expr) + assert "type: count" in block and expr not in block, f"{expr} -> {block}" + + # COUNT(NULL) is always 0, not a row count: it must NOT be flattened to type: count. + null_block = export_measure("COUNT(NULL)") + assert "type: number" in null_block and "COUNT(NULL)" in null_block def test_lookml_export_spaced_count_star_maps_to_native_count(): From b4c61b96fe7108793051475c09866378f44a35bc Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Fri, 10 Jul 2026 10:12:39 -0700 Subject: [PATCH 3/3] Skip zero-column aggregates on LookML export instead of emitting broken type:number The constant-count guard only diverted non-null COUNT constants. Other zero-column aggregates -- COUNT(NULL), COUNT(DISTINCT 1), SUM(1), MAX('x') -- still exported as type: number with SQL that references no column, so re-importing built an opaque complete-SQL metric with an empty referenced-column set and compiling it produced an empty model CTE (SELECT ... FROM with no select list). Skip an UNFILTERED aggregate whose SQL references no column, with a warning. A FILTERED zero-column aggregate still folds (COUNT(*) with a filter -> COUNT(CASE WHEN ... THEN 1 END)), which references the filter's columns and runs. --- sidemantic/adapters/lookml.py | 35 ++++++++++++++++++++++++ tests/adapters/lookml/test_edge_cases.py | 16 ++++++----- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/sidemantic/adapters/lookml.py b/sidemantic/adapters/lookml.py index 696da80e..cdc2b7d3 100644 --- a/sidemantic/adapters/lookml.py +++ b/sidemantic/adapters/lookml.py @@ -2653,6 +2653,25 @@ def _one(m): return " AND ".join("(" + _resolve(f).replace("{model}", "${TABLE}") + ")" for f in filters) + @staticmethod + def _aggregate_references_column(sql: str) -> bool: + """True if ``sql`` references at least one COLUMN (not just constants/functions). + + A zero-column aggregate (``COUNT(NULL)``, ``COUNT(DISTINCT 1)``, ``SUM(1)``) exported as a + LookML ``type: number`` re-imports as an opaque complete-SQL metric whose referenced-column + set is empty, so compiling it builds an empty model CTE (``SELECT ... FROM`` with no select + list). Callers use this to skip such measures. On a parse failure assume it DOES reference a + column, so a genuine (unparseable) column expression is not wrongly dropped. + """ + import sqlglot + from sqlglot import expressions as exp + + try: + tree = sqlglot.parse_one(sql.replace("{model}", "__m__").replace("${TABLE}", "__m__")) + except Exception: + return True + return any(True for _ in tree.find_all(exp.Column)) + @classmethod def _fold_filters_into_aggregate(cls, agg_sql: str, filters: list[str], model: Model) -> str | None: """Fold ``filters`` into a single-outer-aggregate SQL expression. @@ -3007,6 +3026,22 @@ def _export_view(self, model: Model, graph: SemanticGraph) -> dict: # derived metric over an empty CTE (SELECT FROM ...), which the # compiler rejects. Native type: count round-trips cleanly. measure_def["type"] = "count" + elif not metric.filters and not self._aggregate_references_column(col_sql): + # ANY OTHER zero-column aggregate -- COUNT(NULL), COUNT(DISTINCT 1), + # SUM(1), MAX('x') -- has the same fate as a bare constant count: a + # type: number re-imports as an opaque complete-SQL metric whose + # referenced-column set is empty, so compiling it builds an empty model + # CTE (SELECT ... FROM with no select list). Unlike a plain row count it + # has no faithful native form, so skip it with a warning. (A FILTERED + # one falls through: folding the filter in makes it reference the + # filter's columns, e.g. COUNT(*) -> COUNT(CASE WHEN ... THEN 1 END).) + logger.warning( + "Metric %r has a zero-column aggregate SQL (%r) with no LookML " + "equivalent that round-trips; skipping on export.", + metric.name, + col_sql, + ) + continue else: measure_def["type"] = "number" if metric.filters: diff --git a/tests/adapters/lookml/test_edge_cases.py b/tests/adapters/lookml/test_edge_cases.py index 7325d4c5..655b931d 100644 --- a/tests/adapters/lookml/test_edge_cases.py +++ b/tests/adapters/lookml/test_edge_cases.py @@ -4066,8 +4066,9 @@ def test_lookml_export_count_constant_uses_native_count_type(): COUNT(1)/COUNT(0), plus COUNT(TRUE), COUNT('x'), COUNT(1.0), COUNT(.5) all count every row. Exporting them as type: number would re-import as a zero-column complete-SQL metric whose - query hits an empty model CTE (SELECT FROM ...). COUNT(NULL) is NOT a row count (always 0), so - it must stay a type: number. + query hits an empty model CTE (SELECT FROM ...). Every OTHER zero-column aggregate that is NOT + a plain row count -- COUNT(NULL), COUNT(DISTINCT 1), SUM(1), MAX('x') -- has no faithful native + form, so it is SKIPPED (not emitted as a broken type: number). """ import re import tempfile @@ -4088,15 +4089,16 @@ def export_measure(expr): ) out = tempfile.mktemp(suffix=".lkml") LookMLAdapter().export(graph, out) - return re.search(r"measure: c \{.*?\n \}", open(out).read(), re.S).group(0) + m = re.search(r"measure: c \{.*?\n \}", open(out).read(), re.S) + return m.group(0) if m else None for expr in ("COUNT(1)", "COUNT(0)", "COUNT(TRUE)", "COUNT('x')", "COUNT(1.0)", "COUNT(.5)"): block = export_measure(expr) - assert "type: count" in block and expr not in block, f"{expr} -> {block}" + assert block and "type: count" in block and expr not in block, f"{expr} -> {block}" - # COUNT(NULL) is always 0, not a row count: it must NOT be flattened to type: count. - null_block = export_measure("COUNT(NULL)") - assert "type: number" in null_block and "COUNT(NULL)" in null_block + # Zero-column aggregates that are NOT plain row counts have no round-trippable form -> skipped. + for expr in ("COUNT(NULL)", "COUNT(DISTINCT 1)", "SUM(1)", "MAX('x')"): + assert export_measure(expr) is None, f"{expr} should be skipped, not exported" def test_lookml_export_spaced_count_star_maps_to_native_count():