feat(ingest/powerbi): follow references into queries the model does not load - #19372
feat(ingest/powerbi): follow references into queries the model does not load#19372puneetagarwal-datahub wants to merge 9 commits into
Conversation
|
Linear: ING-3352 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 The M-query resolver gains a When building upstream lineage aspects, duplicate dataset upstreams and fine-grained column edges are deduplicated when multiple M paths converge on the same source. Integration and parser tests cover diamond Reviewed by Cursor Bugbot for commit 3e094a0. 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! |
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Connector Tests ResultsAll connector tests passed for commit To skip connector tests, add the Autogenerated by the connector-tests CI pipeline. |
There was a problem hiding this comment.
1 issue found across 6 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/parser.py">
<violation number="1" location="metadata-ingestion/src/datahub/ingestion/source/powerbi/m_query/parser.py:78">
P2: When `native_query_parsing=False`, a hidden regular query containing `Value.NativeQuery` in SQL text or an M comment is removed before parsing, losing valid lineage. Detect an actual NativeQuery function call rather than scanning raw expression text.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| shared_texts = expressions or {} | ||
| if not config.native_query_parsing: | ||
| withheld = [ | ||
| name for name, text in shared_texts.items() if "Value.NativeQuery" in text |
There was a problem hiding this comment.
P2: When native_query_parsing=False, a hidden regular query containing Value.NativeQuery in SQL text or an M comment is removed before parsing, losing valid lineage. Detect an actual NativeQuery function call rather than scanning raw expression text.
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/parser.py, line 78:
<comment>When `native_query_parsing=False`, a hidden regular query containing `Value.NativeQuery` in SQL text or an M comment is removed before parsing, losing valid lineage. Detect an actual NativeQuery function call rather than scanning raw expression text.</comment>
<file context>
@@ -66,10 +66,30 @@ def get_upstream_tables(
+ shared_texts = expressions or {}
+ if not config.native_query_parsing:
+ withheld = [
+ name for name, text in shared_texts.items() if "Value.NativeQuery" in text
+ ]
+ if withheld:
</file context>
There was a problem hiding this comment.
Valid in principle, but leaving it as-is deliberately — the raw-text scan is what native_query_parsing=False already means in this file.
The pre-existing check on the table's own expression, six lines below at parser.py:115, is the same scan:
if not config.native_query_parsing and "Value.NativeQuery" in expression:So a comment or a SQL string mentioning Value.NativeQuery already suppresses the root expression today. This PR makes references to hidden queries followable, which would otherwise let a native query one hop away be parsed despite the flag — the filter exists to keep the two paths behaving the same. Making it precise on one side only would leave them inconsistent.
Detecting an actual Value.NativeQuery invoke node requires parsing the expression first, which is the thing the flag is there to avoid. And the failure mode is conservative: a false positive skips a query the operator has already opted out of parsing, so no wrong lineage is produced — it just isn't followed. Tightening both checks to be AST-based is a reasonable change, but it belongs in its own PR since it alters behaviour for the root expression too.
|
On the red Coverage is only uploaded from the Gradle matrix legs, and those run Measured locally over
Adding |
0c3d423 to
bed6673
Compare
…ot load
A query with "Enable load" switched off is not a table in the model. It has
no entity of its own, so a loaded table built on one had nothing to point an
edge at and lost its lineage entirely:
Combined Actions load ON -- the only table
= Table.Combine({ #"Intercom Only", #"Intercom Excluded" })
both load OFF, both reading
#"Combined Actions Base" load OFF, holds Databricks.Catalogs
Those names are bound nowhere in the table's own expression, so the walk
stopped at the first one.
The M for them is already in hand: the scan is asked for datasetExpressions
and the response was being discarded. Keep it on the dataset and, when no
enclosing let binds a name, follow it into the query that defines it. Since
those queries are not entities, the chain is followed inline and the data
source it ends at is attributed to the loaded table.
A parameter is not a query -- following one leads to a literal -- so
expressions carrying IsParameterQuery are left alone. Names match the way
identifier lookup does elsewhere, case-insensitively.
Two queries referencing each other stop rather than recurse, and a chain
longer than ten references is treated as not a real model shape. A query
reached by several routes is parsed once, not once per route.
Reaching the same upstream by more than one route is legitimate -- two
queries filtering a common source, or both branches of a conditional -- but
the aspect should name it once, so upstreams are now deduplicated as they
are collected. That also covers Table.Combine over two identical sources,
which could already duplicate before this change.
Five things the first pass got wrong. The native_query_parsing opt-out only ever looked at the table's own expression. A native query one reference away was parsed regardless, so the flag could be bypassed by hiding the query behind another one. Withhold those expressions up front, where the same check already lives. A query that fails to parse was recorded nowhere and logged at debug, which is the shape of failure this whole area is meant to stop producing. Record it against the walk and warn once per table, naming the queries. The parse cache stored successes only, so a query that timed out was retried by every route that reached it -- each retry paying the full timeout. Cache the failure too. Only parse, bridge and timeout failures are caught now. Anything else coming out of the bridge is a defect rather than bad input and should not be turned into missing lineage. Deduplicating upstreams also skipped the column lineage attached to the second route, so mappings only that route had were dropped. Deduplicate the upstream alone and leave column lineage as it was. `expressions` sits after `dependent_on_artifact_id` so positional construction of PowerBIDataset keeps its meaning. Every caller in the repo passes keywords, so nothing here changes; it is the external contract that matters.
A table that reaches the same upstream by two routes had the upstream named once but its column edges appended once per route. Key them on the fields they join so a route contributing a mapping the others did not still reaches the aspect. The golden comparison treats lists as unordered, so a repeated edge reads as equal to a single one -- the integration test asserts on the counts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bddaab1 to
be60c8e
Compare
It sat at the end of test_ingest.py, where every other new golden test also lands, so two branches adding one each collide. Moving it between the existing tests puts it in its own hunk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relocation left two, which is a change to the file's tail -- the exact region another branch appends to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…teps Every other branch of the walk threads them; this one did not, so a parenthesized step whose head is a query the model does not load resolved to nothing. The default argument made it silent rather than an error. 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.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 392a098. Configure here.
The fixture and golden carried query and table names taken from the reporting environment. Renamed to placeholders that keep the structure under test -- one loaded table combining two filtered views of a query that is not loaded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Closes the "Enable load switched off" report — 70 tables on the reporting tenant.
Summary
A query with "Enable load" switched off is not a table in the model. It gets no entity of its own, so a loaded table built on one had nothing to point an edge at and lost its lineage entirely:
Nothing in
Merged Rows' own expression binds those names, so the walk stopped at the first one.The M was already in hand
The scan is already asked for
datasetExpressions, and the response was being discarded. So this reads what we were already paying for. Microsoft's own docs example is the same shape — a table whose M islet Source = Revenues … in Source, withRevenuesa shared expression holdingSql.Database(…). The reporter confirmed it independently from their side before we looked.Approach
Keep the expressions on the dataset, and when no enclosing
letbinds a name, follow it into the query that defines it. Because those queries are not entities, the chain is followed inline and the data source it ends at is attributed to the loaded table — emitting a Power BI → Power BI edge is not an option, there is nothing at the other end.Guards, each with a test:
IsParameterQueryare left alone.Verified against the reported tenant's own M
Their ingestion log carries the real template — the connector bound to a named step, with the catalog, schema and table names all supplied as parameters rather than literals:
Running that shape as the hidden query resolves. Worth knowing for anyone reading the fixtures: an earlier version of my test used string literals, and the parameterised form is what they actually write.
One caveat that is not fixable here — if the hidden query's own M omits a
Kind=on a navigation step, which three of their tables do, the chain still dead-ends until #19364 is in. Measured both ways:Kindpresent resolves on this branch alone,Kindabsent needs #19364 too.Two changes beyond the feature
Converging routes should name an upstream once. Reaching the same upstream by more than one route is legitimate — two queries filtering a common source, or both branches of a conditional — but the aspect should name it once. Upstreams and their column edges are both deduplicated as they are collected, keyed on the fields they join so a route contributing a mapping the others did not still reaches the aspect.
This was needed here (the diamond above reaches the same table twice) and it also covers
Table.Combine({A, A}), which could already duplicate before this change.The golden could not see the duplicate.
check_golden_filedefaults toignore_order=True, so DeepDiff compares lists as sets and a repeated edge reads as equal to a single one. The golden held two column edges while the pipeline emitted four, and the test passed. The integration test now asserts the counts directly rather than trusting the golden. That blindspot applies to every golden in this suite.Shared queries through parentheses. Every branch of the walk threads the shared queries except the parenthesized one, which arrives from #19364 in the base and has no such argument. Because the parameter defaults to
None, a parenthesized step whose head is a hidden query resolved to nothing, silently. Fixed with a test that fails when the argument is dropped — #19364's own parenthesized tests pass either way, so only the new one catches it.Testing
Six parser-level tests and a golden. The golden asserts the shape that matters: one upstream on the loaded table plus column-level lineage, from a fixture whose dataset carries the three hidden queries and one parameter expression.
Verified the golden guards the feature — stubbing the expressions out makes it fail.
382 passed, 2 xfailed· ruff, format and mypy clean · no existing goldens touched.Known limitation, not introduced here
A hidden query written as a bare expression with no
letwrapper yields no lineage and no warning. That is the pre-existing behaviour of the root-expression path —resolve_to_data_access_functionsrequires aLetExpressionand returns[]equally quietly — and it is already documented atparser.py:37from #18720. Measured on both paths; this PR inherits the limitation rather than adding it. Handling let-less roots changes behaviour for every table's own expression and belongs in its own PR.Checklist
Summary by cubic
Restores lineage for Power BI tables that reference queries with “Enable load” off by following those shared expressions inline. Before, unbound names in a table’s M stopped the walk and dropped lineage; now we resolve into the hidden query and attribute its data source to the loaded table.
expressionsand follows unbound identifiers into referenced queries, honoringnative_query_parsing=False.Written for commit 3e094a0. Summary will update on new commits.