Skip to content

fix(dbt): substitute DERIVED metric references in a single pass - #353

Open
ayushtkn wants to merge 3 commits into
apache:mainfrom
ayushtkn:fix/dbt-derived-metric-substitution
Open

fix(dbt): substitute DERIVED metric references in a single pass#353
ayushtkn wants to merge 3 commits into
apache:mainfrom
ayushtkn:fix/dbt-derived-metric-substitution

Conversation

@ayushtkn

@ayushtkn ayushtkn commented Sep 2, 2026

Copy link
Copy Markdown
Member

Summary

MSIToOssieConverter._resolve_derived inlined each input metric of a DERIVED metric
with its own re.sub call, run over the string produced by the previous iteration.
Because every pass re-scanned text that earlier passes had inserted, and because the
resolved expression was passed as re.sub's replacement template, the emitted Ossie
expression could be silently corrupted in two ways.

1. A later reference matched text an earlier one inserted. MetricFlow metrics are
commonly named after the column they aggregate, so this is easy to hit. With measures
gross = SUM(net) and net = SUM(net_amount) and a DERIVED metric expr = "gross - net":

before:  SUM(orders.SUM(orders.net_amount)) - SUM(orders.net_amount)
after:   SUM(orders.net) - SUM(orders.net_amount)

2. Backslashes in the resolved SQL were read as replacement escapes. A backslash
surviving from a metric filter was reinterpreted on inlining. With a SIMPLE metric
filtered on {{ Dimension('order__path') }} LIKE 'a\b', inlined into expr = "revenue * 2":

before:  SUM(CASE WHEN order__path LIKE 'a<backspace>' THEN orders.amount END) * 2
after:   SUM(CASE WHEN order__path LIKE 'a\b' THEN orders.amount END) * 2

The \b became a literal backspace character. Other sequences (e.g. \d) raised
re.error: bad escape and aborted the conversion instead.

The change. Collect the {reference: resolved expression} pairs first, then
substitute them in a single pass using an alternation pattern with a callback
replacement. A callback is not template-expanded, so one change fixes both problems.
This is the same approach already used in the Databricks converter
(metric_view_to_ossie.py, re.sub(r"\bsource\.", lambda _m: ...)).

Related Issues

NA

Checklist

Specification

  • Spec changes are included in core-spec/ and follow the existing structure
  • Spec changes have been discussed on the mailing list or in a linked issue
  • Breaking changes to the spec are clearly called out in the summary

Ontology

  • Ontology changes in ontology/ are consistent with spec changes
  • New or modified terms are defined and documented

Converters

  • Converter logic in converters/ is updated to reflect spec or ontology changes
  • New converters include tests under the converter's test directory

Validation

  • Validation rules in validation/ are updated if the spec changed
  • New validation cases are covered by tests

Documentation

  • docs/ is updated to reflect any user-facing changes
  • New features or behaviors are documented with examples where appropriate
  • CONTRIBUTING.md is updated if the contribution process changed

Examples

  • examples/ are added or updated for any new spec constructs or converter support

Tests

  • All existing tests pass (pytest / CI green)
  • New functionality is covered by tests

Compliance

  • ASF license headers are present on all new source files
  • No third-party dependencies are added without PMC/IPMC approval

_resolve_derived ran one re.sub per input metric over the string produced
by the previous iteration, so each pass re-scanned text that earlier
passes had inserted. A metric named after a column appearing in an
already-inlined expression was expanded twice, e.g. `gross - net` with
gross = SUM(orders.net) yielded SUM(orders.SUM(orders.net_amount)).

Passing the resolved expression as re.sub's replacement also let it be
read as a template: a backslash surviving from a metric filter was
reinterpreted, turning `LIKE 'a\b'` into a literal backspace character
(and raising re.error for sequences such as \d).

Collect the references first and substitute them in one pass with a
callback replacement, which is not template-expanded, fixing both.
Copilot AI lite review requested due to automatic review settings September 2, 2026 06:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The refactor directly addresses the described substitution correctness issues and is covered by targeted regression tests, with only minor non-blocking maintainability feedback.

Pull request overview

This PR fixes DERIVED metric reference inlining in the dbt MSI→Ossie converter by performing all substitutions in a single regex pass with a callback replacement, preventing both accidental re-expansion of already-inlined text and re.sub replacement-escape corruption (e.g., backslashes from filters).

Changes:

  • Refactors MSIToOssieConverter._resolve_derived to collect reference→resolved-expression mappings and substitute them in one re.sub pass via a compiled alternation pattern and callback.
  • Adds regression tests covering (1) no re-expansion when one metric name appears inside another metric’s resolved SQL and (2) preservation of backslashes originating from filters.
File summaries
File Description
converters/dbt/src/ossie_dbt/msi_to_ossie.py Changes DERIVED metric substitution to a single-pass alternation+callback approach to avoid re-expansion and replacement-escape issues.
converters/dbt/tests/test_msi_to_ossie.py Adds tests validating single-pass substitution behavior and correct backslash preservation from filtered metrics.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +364 to +367
# longer identifier; sorting by length keeps the alternation order stable
# and independent of the order metrics happen to be declared in.
pattern = re.compile(
r"\b(" + "|".join(re.escape(ref) for ref in sorted(replacements, key=len, reverse=True)) + r")\b"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

sorted(replacements, key=len, reverse=True) is only deterministic for
references of differing lengths; ties keep insertion order, which is the
order the input metrics are declared in. Sort by length and then by name
so the compiled pattern is fully stable.

Matching is unaffected: the \b anchors and the equal length mean at most
one tied alternative can match at a given position either way.
@jbonofre
jbonofre self-requested a review September 7, 2026 11:42
resolved = f"({resolved})"
expr = re.sub(rf"\b{re.escape(ref)}\b", resolved, expr)
return expr
replacements[ref] = resolved

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that collecting into replacements[ref] = resolved changes the collision behavior from "first entry wins" to "last entry wins". I'm not sure it's addressed anywhere.

if a derived metric lists the same input metric twice with different per-input filters and no alias, this dict collapses to one entry and both occurrences get F2's SQL. This input shape isn't rejected upstream: MetricFlow's DerivedMetricRule._validate_alias_collision only compares entries that have an alias set, so two unaliased duplicates sail through validation.

Worth either erroring on a duplicate unaliased ref here, or confirming this silent overwrite is intentional (and so it should be documented).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanx @jbonofre for the review. Good catch — silent last-wins wasn’t intentional. MetricFlow doesn’t reject this shape (DerivedMetricRule._validate_alias_collision only compares aliased entries), and with a single token in expr neither resolution is more correct. Raised a ValueError when the same reference resolves differently, and pointed at distinct aliases as the fix. Identical duplicates stay accepted since they’re redundant, not ambiguous. Covered in 6952a43.

profit_ossie = next(m for m in _ossie_metrics(result) if m.name == "profit")
assert profit_ossie.expression.dialects[0].expression == "SUM(orders.amount) - SUM(orders.cost_amount)"

def test_derived_metric_does_not_re_expand_an_inlined_reference(self) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest to add a test for a DERIVED metric whose type_params.metrics contains two entries resolving to the same reference (same name, same alias) with different filters?

That's the case where the dict-based replacements collapses to one entry and silently picks whichever occurrence was declared last (there's no coverage for that ordering behavior right now).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two tests in 6952a43: one that rejects the same reference listed twice with differing filters, and one that accepts a redundant identical duplicate (revenue + revenue). Went with reject rather than asserting last-wins ordering.

Collecting the references into a dict changed the behaviour for a DERIVED
metric that lists the same input metric twice under one reference: the
sequential re.sub applied the first occurrence, the dict keeps the last.
For two unaliased occurrences carrying different filters, `expr` holds a
single token for both, so neither choice is more correct than the other.

MetricFlow does not reject the shape upstream — its
DerivedMetricRule._validate_alias_collision only compares entries that set
an alias — so raise here instead, and point at aliases as the fix.
Occurrences that resolve to identical SQL are redundant rather than
ambiguous and are still accepted.
@jbonofre
jbonofre self-requested a review September 8, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants