fix(ingest/powerbi): resolve M-Query names against enclosing let scopes - #19370
fix(ingest/powerbi): resolve M-Query names against enclosing let scopes#19370puneetagarwal-datahub wants to merge 6 commits into
Conversation
|
Linear: ING-3351 Thanks for your contribution! We have created an internal ticket to track this PR. A member of the core DataHub team will be assigned to review it within the next few business days - you will get a follow-up comment once a reviewer is assigned. |
PR SummaryOverview When continuing from a binding, the chain is truncated to the scope that defined the name, so outer steps cannot pick up variables only introduced in a nested Adds integration tests for nested outer access, shadowing, outer-vs-nested visibility, cycles, and unresolvable self-init; one xfail remains for nested Reviewed by Cursor Bugbot for commit adaa416. Bugbot is set up for automated code reviews on this repo. Configure here. |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Connector Tests ResultsAll connector tests passed for commit To skip connector tests, add the Autogenerated by the connector-tests CI pipeline. |
The walk carried one let at a time. Descending into a nested let replaced
the enclosing scope instead of adding to it, so a nested body could not
reach any step its parents bound:
Source = Databricks.Catalogs(...),
db = Source{[Name="cat", Kind="Database"]}[Data],
sch = db{[Name="sch", Kind="Schema"]}[Data],
out = let tbl = sch{[Name="tbl", Kind="Table"]}[Data] in tbl
`sch` was unreachable from the inner let, so the table produced no lineage
at all. Nested lets are common in Power BI-generated M and `Source` is the
default step name, so this is not an unusual shape.
Carry the enclosing scopes as a chain, innermost last, and look a name up
from the inside out so the nearest binding wins.
The chain is truncated at the scope that binds the name, because a step
bound by an outer let is evaluated in that let's scope and cannot see names
a nested one introduces. Without that, `a = sch{...}` at the outer level
resolves against a nested `sch` and invents an upstream from an expression
Power BI itself would reject -- verified, it produces `cat.inner.tbl`.
The circular-reference guard now keys on the scope that actually bound the
name rather than whichever let happened to be current.
0c3d423 to
bed6673
Compare
The parenthesized-expression walk was written against the old current_let/current_let_id pair. With the scope chain in place the names no longer exist, and the NameError is swallowed by the caller's broad handler -- so lineage went missing silently rather than failing loudly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| return | ||
| binding_let_id, resolved, binding_scopes = found | ||
|
|
||
| # Circular reference guard: (binding let id, variable name) pair |
There was a problem hiding this comment.
Make the circular-reference guard case-insensitive (casefold()), matching how identifiers are looked up, to prevent loops when the same variable is written with different casing.
There was a problem hiding this comment.
Done in 4aa549e — you're right that the guard was inconsistent with the lookup. resolve_identifier compares case-insensitively (its own comment says "M-Query variable names are case-insensitive") while the guard keyed on the name as written.
One thing worth being straight about: I went looking for the loop and could not produce one. Measured before the change:
| expression | result |
|---|---|
a = A (self-ref, different spelling) |
guard fired, no lineage |
a = B, b = A (cycle, mixed spelling) |
guard fired, no lineage |
a = b, b = a (same spelling) |
guard fired, no lineage |
All terminated. The reason is that each distinct spelling gets its own guard entry and an expression contains finitely many spellings, so the guard still fires — just one traversal later than it should. So this is a consistency fix rather than a hang fix, and the test I added passes with or without it. I'd rather say that than imply I closed a live bug.
Still worth making the change: keying the guard on the same normalisation the lookup uses removes a discrepancy that would bite the first time something else depends on the guard firing promptly.
| return None | ||
|
|
||
|
|
||
| def _resolve_in_scopes( |
There was a problem hiding this comment.
The truncation behavior is correct, but we should clarify one edge case: how should a nested a = a be resolved when a is also defined in the outer scope?
There was a problem hiding this comment.
Good question, and the answer needed pinning down rather than reasoning about — added test_nested_self_reference_does_not_fall_back_to_the_outer_binding in 4aa549e.
let
Source = Databricks.Catalogs(...),
a = Source{[Name="my_catalog", Kind="Database"]}[Data], -- outer `a`, a real navigation
out = let a = a in a -- nested `a = a`
in
out
Result: no lineage. Which is the behaviour we want, for two reasons.
The nested binding shadows the outer one for the whole nested body, so the right-hand a is the inner a — a genuine self-reference, which is what Power BI itself rejects as a cyclic reference. So the walk resolves a to the inner binding, the guard fires on (inner let id, "a"), and the branch stops.
The alternative — falling through to the outer a after the self-reference is detected — would emit my_catalog as an upstream for an expression Power BI will not even evaluate. That is the same failure mode the truncation exists to prevent, just reached from the other direction. Measured: 0 data-access functions found, and the outer a does point at a real navigation, so if it were falling back we would see 1.
Worth noting the guard is what makes this safe, not the truncation — the truncation governs which scopes a resolved value is evaluated in, while this case is decided by the guard firing on the inner binding. Two separate mechanisms, which is why it deserved its own test rather than being assumed covered.
There was a problem hiding this comment.
Correction — my answer above was wrong, and Bugbot caught it on the same commit.
I said the nested a = a is a self-reference that Power BI rejects, and that resolving it against the outer binding would invent lineage. The opposite is true. From the M spec:
the let-expression evaluates the sub-expression for each variable with an environment containing each of the variables of the let except the one being initialized
with this example:
[
a = [
x = 1, // environment: b, x (outer), y, z
...For the binding being initialized, its own name is excluded and the enclosing one is in scope. So a = a reads the outer a — valid M that Power BI refreshes — and is only an error when nothing encloses it. A plain reference is exclusive; @a is the inclusive form.
So the behaviour I described as correct was a lineage-loss bug: the guard fired on a reference that was never circular. Fixed in 21e289c — resolution now skips a binding while its own value is being walked. The test I pointed you at is replaced by two, one per case, and the positive one fails if the exclusion is removed.
Your original question was the right one to ask; I answered it confidently from the wrong model of M's scoping instead of checking the spec. Thanks for pushing on it.
Identifier lookup compares case-insensitively because M variable names are, but the guard keyed on the name as written. A cycle spelled inconsistently was admitted one extra traversal before the guard fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4aa549e. Configure here.
A plain identifier reference in M is exclusive: the spec evaluates each let variable "with an environment containing each of the variables of the let except the one being initialized". Resolution always preferred the innermost binding, so a nested `a = a` resolved to itself, tripped the circular-reference guard, and lost the lineage the enclosing `a` holds -- for M that Power BI refreshes without complaint. Skip a binding while its own value is being walked. With nothing enclosing to resolve to, the reference is an error in M and still yields no lineage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 2 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/resolver.py">
<violation number="1" location="metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/resolver.py:378">
P1: Custom agent: **Enforce Strict Maintainability Standards**
When an inner same-name cycle exists alongside an outer binding, this lookup skips the inner binding because `seen` retains historical visits, then resolves the outer binding and emits fabricated lineage. Track only active bindings for recursion, or preserve the inner binding and stop the cycle without falling through to the outer scope.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| """ | ||
| for index in range(len(scopes) - 1, -1, -1): | ||
| let_id, let_node = scopes[index] | ||
| if (let_id, name.casefold()) in initializing: |
There was a problem hiding this comment.
P1: Custom agent: Enforce Strict Maintainability Standards
When an inner same-name cycle exists alongside an outer binding, this lookup skips the inner binding because seen retains historical visits, then resolves the outer binding and emits fabricated lineage. Track only active bindings for recursion, or preserve the inner binding and stop the cycle without falling through to the outer scope.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/resolver.py, line 378:
<comment>When an inner same-name cycle exists alongside an outer binding, this lookup skips the inner binding because `seen` retains historical visits, then resolves the outer binding and emits fabricated lineage. Track only active bindings for recursion, or preserve the inner binding and stop the cycle without falling through to the outer scope.</comment>
<file context>
@@ -353,17 +353,30 @@ def _unwrap_csv(elem: object) -> Optional[dict]:
"""
for index in range(len(scopes) - 1, -1, -1):
let_id, let_node = scopes[index]
+ if (let_id, name.casefold()) in initializing:
+ continue
resolved = resolve_identifier(node_map, let_node, name)
</file context>
…fail A plain reference in M excludes the binding being initialized, so a nested `a = a` reads the enclosing `a`. Resolution prefers the innermost binding, so that lineage is lost. Recorded as a strict xfail rather than fixed here: no reported query uses the shape, and correcting it widens this change beyond scope-chain resolution. Strict, so a later fix fails the run until the marker goes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Summary
The M-Query walk carried one
letat a time. Descending into a nestedletreplaced the enclosing scope instead of adding to it, so a nested body could not reach any step its parents bound:schwas unreachable from the innerlet, so this table produced no lineage at all. Nested lets are common in Power BI-generated M andSourceis the default step name, so this is not an exotic shape.Approach
Carry the enclosing scopes as a chain, innermost last, and resolve a name from the inside out so the nearest binding wins. This replaces the
current_let/current_let_idpair with a singlescopesargument, so the walk signature gets shorter rather than longer.The chain is truncated at the scope that binds the name. A step bound by an outer
letis evaluated in thatlet's scope and cannot see names a nested one introduces. Without the truncation, this:resolves
schagainst the nested binding and invents an upstream from an expression Power BI itself would reject. Verified by disabling the truncation: it emitscat.inner.tbl. That property is pinned by a test.The circular-reference guard now keys on the scope that actually bound the name, rather than whichever
lethappened to be current.Stacked on #19364
This is not for convenience. #19364 adds a
ParenthesizedExpressionbranch to the same walk, written against the oldcurrent_let/current_let_idpair. The two changes are textually compatible — git merges them without a conflict — and the result is broken:The resulting
NameErroris swallowed by the caller's broadexcept Exception, so it does not crash. It just returns no lineage, silently, for exactly the parenthesized shape #19364 exists to fix. Neither branch fails on its own; only the combination does.Verified by merging the two and running the suite:
test_databricks_parenthesized_navigation_stepand..._in_if_branchboth fail withassert 0 == 1. This PR adapts that call site, which is why it stacks here rather than on master — CI now proves the combination before either lands.Behaviour verified
letreaching an outer stepa = aa = b, b = aTesting
373 passed, 2 xfailed· ruff, format and mypy clean · no existing goldens touched.From review
a = Aanda = B, b = Aboth already terminated — each distinct spelling gets its own guard entry and spellings are finite — so this is a consistency fix, not a hang fix, and its test passes either way.a = ashould read the enclosinga; resolution prefers the innermost binding, resolves it to itself, and the guard drops the lineage. Left as a strict xfail to keep this change to scope-chain resolution — no reported query uses the shape, and there are zero self-initialising steps in the reporting tenant's logged M.strict=Trueverified: applying the fix turns itXPASSand fails the run, so it cannot rot.Note for reviewers
This is a change to resolution behaviour, not just to which expressions are recognised — worth the closer look of the M-Query PRs currently in flight. The swallowed-
NameErrorpath above is the part I would most like a second opinion on: a broadexcept Exceptionaround the walk turns programming errors into missing lineage, and that is how both this and a second dropped-argument bug on #19372 stayed invisible. Narrowing it is out of scope here but worth its own change.It also touches the same function as #19365; whichever merges second needs a rebase there. The conflict is mechanical (that PR renames the walk's accumulator, this one replaces its scope arguments) but it is not a one-liner.
Checklist
Summary by cubic
Resolves M-Query identifier names against the full chain of enclosing let scopes so nested bodies can reach outer steps, while outer steps can’t see nested-only names. Also preserves scope through parenthesized expressions and tightens the circular-reference guard to be case-insensitive and keyed to the binding let.
Written for commit adaa416. Summary will update on new commits.