Skip to content

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

Merged
jbonofre merged 4 commits into
apache:mainfrom
ayushtkn:fix/dbt-derived-metric-substitution
Sep 21, 2026
Merged

jbonofre merged 4 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

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.

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

@jbonofre
jbonofre self-requested a review September 8, 2026 14:18
@ayushtkn

Copy link
Copy Markdown
Member Author

Hi @jbonofre does the latest changes look good? Let me know if it requires further changes or investigation :-)

@jbonofre

Copy link
Copy Markdown
Member

@ayushtkn I'm doing a new pass. Thanks!

@jbonofre jbonofre left a comment

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.

Thanks for the fix, I think we are very close!

The single-pass substitution change itself is solid and well covered by the new tests.

Before merging, I propose:

  1. to fix the two false positives at msi_to_ossie.py:367: a duplicated input metric with no per-input filter vs one that restates the parent own filter currently render to different text ("(X)" vs "(X) AND (X)") and trips the check even though they are logically identical. And a duplicated-but-unused reference (never appearing in expr) should not be checked at all.
  2. to fail soft instead of hard-raising: catch this case the same way the converter handles every other unsupported shape (CONVERSION_METRIC_DROPPED, etc), append a ConverterIssue and drop that one metric, rather than letting ValueError propagate uncaught through cli.py and kill the whole run.

I would be happy to pair on this or take a stab at the fix myself if that's faster.
Once those two are addressed I'm good to approve and merge.

_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.
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.
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.
Addresses review feedback on the duplicate DERIVED reference guard.

False positives. Two duplicate listings that are not actually ambiguous
were rejected:

- An occurrence restating a filter the enclosing metric already applies
  resolved to "(X) AND (X)" where the bare occurrence resolved to "X",
  so identical row sets compared unequal. _merge_filter_sqls now drops
  repeated fragments; AND is idempotent, so this also stops the
  redundant text reaching the emitted SQL.
- A duplicated reference the expression never substitutes cannot affect
  the result, so only references the expression actually uses are
  checked.

Hard failure. An ambiguous reference now raises a dedicated
AmbiguousDerivedReferenceError, which convert() catches to drop that one
metric and record AMBIGUOUS_REFERENCE_METRIC_DROPPED, matching how every
other unsupported shape is handled instead of propagating a ValueError
out through cli.py and ending the run. The CLI's reason table covers the
new issue type, with a test asserting every type has an entry, since the
lookup is by key.
@ayushtkn
ayushtkn force-pushed the fix/dbt-derived-metric-substitution branch from 6952a43 to f176164 Compare September 21, 2026 12:53
@ayushtkn

Copy link
Copy Markdown
Member Author

Thanx @jbonofre for helping with the review. I have quickly tried and pushed a commit to address the review comment. I am good with if you want to collaborate or wanna take it over as well. I can help with testing and reviewing the changes :-)

@jbonofre
jbonofre self-requested a review September 21, 2026 13:52
@jbonofre
jbonofre merged commit 6d37d7b into apache:main Sep 21, 2026
4 checks passed
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