Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dependency graph and reachability

How the model and report ASTs become one graph, and what "unused" means. Every rule here is a decision someone made on purpose; changing one changes what scan tells users to delete. Read this before touching src/graph.rs or its submodules.

Shape

One DependencyGraph per semantic model, shared by every report that connects to it. Nodes are ObjectIds — every model object and report measure, whether or not anything references them, so an isolated object can be reported unused. Edges point from user to used and carry their Provenance as the petgraph edge weight, written once at build time. Reverse queries (consumers_of) are plain reads; a second view over the graph (ripbi deps) must be pure rendering in the CLI.

Report sites — visuals, pages, bookmarks — are not model objects and have no ObjectId, so report bindings live beside the graph as roots, each with its binding provenance. An RLS role 'Reader' filter edge, by contrast, is an object-to-object edge: the role is a node.

The conservatism rule

Marking an object used too many is harmless. Marking one too few tells a user to delete live code. Where the rules below could go either way, they go wide. In particular: an unqualified [Name] keeps every candidate alive (never UnqualifiedMatches::primary), and an extended resolution adds candidates the plain binder does not know rather than fewer.

The edge catalog

DAX references. For every expression from TabularDatabase::dax_expressions and ReportModel::dax_expressions, every reference binds through dax::bind and edges to each target carry the expression kind as provenance. This is the only path a measure's body, a calculated column, a KPI, an RLS filter, a calculation item, or a function body can keep anything alive.

Extended resolution. Beyond the binder's own answers, a qualified 'Table'[Name] also keeps a same-named hierarchy (ISINSCOPE('Date'[Calendar])) and, when the table is a calculation group, a same-named calculation item ('Time Intelligence'[YTD]) alive. A qualified reference matching nothing keeps its qualifying table alive — the nearest resolvable candidate the written form asserts. Unqualified, bare, and call references that match nothing keep nothing alive: there is nothing resolvable to point at.

M references. Every M expression — a table partition or a shared expression — is lexed (m::bind, see m-lexing.md). The pipeline is M → tables/columns → DAX → reports, so deletion never breaks upstream, and the two directions of a mention split:

  • a shared expression named in the text (a parameter, a staging query) keeps alive — deleting it deletes the query the expression reads, which breaks refresh. M-to-M chains flow one hop per edge;
  • a table named as a merge source (Table.NestedJoin(…, #"Dim Lookup", …)) or as a qualified field access (#"Dim Lookup"[Key]) keeps alive, for the same reason. A partition naming its own table creates no edge, and a dead table's partition keeps nothing alive, because every M edge flows from its owner;
  • a column named by bracket field access (each [Amount]) or by the string arguments of the column-centric built-ins (Table.ExpandTableColumn(Source, "Amount")) keeps nothing alive. The column's M mention is its supply chain — the query keeps producing the column and the model just stops mapping it — so unloading it cannot break refresh. The naming expressions travel with the finding instead (UnusedObject::named_by_m, rendered as the named_in_power_query JSON field and the ⭘ Power Query also names it annotation): unloading is safe, and removing the column from the script entirely means editing those steps too. Only Data columns carry that context — an M step can only name a column it produces, so a calculated column matching an M name is coincidence (auto date/time columns named like Desktop's date-template query), never supply chain.

Matching is by identifier tokens, case-insensitively: names inside comments and unrelated strings do not count (the one deliberate narrowing against the old substring scan, which this rule replaced), while bare identifiers still over-mark — most are let variables that resolve to nothing. Unresolved references stay data, never errors.

Dynamic M parameters. A dynamic M query parameter is a shared expression whose view-time values come from a model column, so a report slicer feeds the M query directly (issue #50). The model records the binding in two halves: the column carries an anonymous marker extended property (ParameterMetadata with "kind": 1), and the parameter expression names the column — parameterValuesColumn: DaysList.Days. Only the second half is modeled: a consumed parameter keeps its bound column alive with the dynamic M parameter binding provenance, because deleting the column breaks the binding and every partition whose M consumes the parameter. The chain is report → consuming table → partition → parameter (M references) → bound column, so the column needs no DAX or report reference of its own. The edge flows one way only: an unconsumed parameter is itself dead, and its bound column dies with it; a binding naming a column the model no longer has keeps nothing alive (misses are data). The other ParameterMetadata shapes are not bindings: "kind": 2 marks field parameters and "version": 0 marks what-if parameters — both stay unmodeled, since their columns are kept alive by ordinary DAX (SELECTEDVALUE) and report references.

Report bindings. Every ReportModel::bindings target is a reachability root with its provenance. Measure targets resolve report-first: within its report, a report measure shadows a model measure of the same name. Aggregation unwraps to its inner field; HierarchyLevel keeps the hierarchy and the level's underlying column; Written falls through the same ladder as a written qualified reference. The phone layout's pages (ReportModel::mobile_pages, issue #49) enumerate like any other page's: a field bound only by a definition.mobile/ visual is live, because phone users see it, and its roots carry the mobile marker so an audit can tell the two layouts apart.

Date-hierarchy bindings over a variation. A visual's date hierarchy under auto date/time is written against the varied (base) table — HierarchyLevel with a PropertyVariationSource — but the hierarchy lives on the engine's hidden LocalDateTable_*. When the named table carries no such hierarchy, resolution follows the model's declaration: the varied column's variation names the relationship (and the default hierarchy), and the binding lands on the related date table's hierarchy and level column. If the serialization dropped the variation object, the relationship is found by shape instead — the one touching the varied column whose other endpoint is a flagged auto date/time table. Without a resolution the coarse fallback applies: the table the binding names stays alive.

Calculation-item selection. A report binding that lands on a calculation-group column — a slicer over the field column, a filter naming an item — can select any of the group's items at query time, so it keeps every item of the group alive, carrying the binding's provenance. Written uses only: structural liveness of the group (its table kept alive by one explicitly named item) deliberately does not spread to the unselected items. DAX that references the column without naming an item ('Date Role'[Date Role] = "By Ship Date") currently keeps only the column alive — the string is data, not a reference the lexer can bind.

Report measures are nodes, not roots. An unused report measure is dead — it is exactly the accumulated bloat this tool looks for. Its body's references stay alive only through it, so they die with it, annotated.

Containment. A used member — column, measure, hierarchy, calculation item — keeps its table alive. A used table keeps its partitions, its relationships, and its engine-managed columns alive (calculated-table columns, calculation-group columns, and calendar columns are materialized with the table and cannot be dropped independently). The flow is one-directional on purpose: a table does not keep its ordinary columns alive, because unused columns in used tables are the bread and butter of the findings.

Sort-by, group-by, hierarchy levels. A used column keeps its sortByColumn and groupByColumns alive; a used hierarchy keeps its levels' columns alive. Dead chains form the other way: an unused sorted column drags its unused sort column along, annotated.

Relationships. Live if either endpoint table is reachable. An active relationship keeps both key columns alive; an inactive one is live only when a live DAX reference (USERELATIONSHIP) activates it — switching one on at query time is DAX's job, and nothing else can. Unactivated, the relationship is itself a finding, and its key columns are findings chained under it (only used by … (also unused)). Roles keep their granted tables and filtered columns alive; roles themselves are seeds, never findings, because security configuration is not bloat.

The relationship rule and the two-pass traversal

The subtle decision: a live table keeps its relationships and key columns alive, but a key column kept alive only as a relationship endpoint does not keep its table alive. A table referenced by nothing but a relationship is still unused — deleting it together with the relationship is safe, and in a star schema where every table is related, this is the difference between table findings and none.

One plain BFS cannot express that (containment would drag the far table in), so reachability runs two passes and the policy falls out of what each pass excludes:

  1. Strong pass — from the roots over every edge except relationship endpoints and the inactive-relationship edges. Containment fires; the result is everything that can keep its table alive.
  2. Weak pass — extends the strong set over every edge except containment and the inactive-relationship edges. The relationships of live tables and their active key columns join here, without propagation into tables; an inactive relationship joins only through a live USERELATIONSHIP reference, whose Dax edge the strong pass already carries.

Unused = every node in neither pass. For any unused object, every referencing object is provably either itself unused, a weakly-live key column, or the table of an inactive relationship the relationship cannot keep alive — which is exactly the also_unused: false case in UsedBy, and the reason unused_objects needs no special-casing to annotate chains.

Zero roots means everything is unused

A model scanned with no reports and no roles has no reachability roots; every object comes back unused. That is the honest answer, not a special case: callers (the CLI) decide whether it is a finding or a missing report and should say so.

The second verdict: auto date/time is a provenance question

Reachability answers "does anything keep this alive?" For the engine's auto date/time machinery (LocalDateTable_* / DateTableTemplate_*) that answer is misleading, because the framework relationship to the user's date column keeps the machinery alive for as long as that column is used — a framework-generated edge, not a real consumer. So the graph carries a second, deliberately non-reachability verdict beside unused_objects: auto_date_time_tables reads the same resolved roots and the same reachability set with a different question — does a report binding land on the machinery?

  • In use — a Provenance::Binding root (or selection edge) lands on the table or one of its members. Alive and used; the advice is to replace it with a real date table.
  • Unused by reports — nothing binds it, yet reachability keeps it alive: pure bloat no dead-code finding can express, because the object is not dead. Disable auto date/time.
  • Dead — reachability never reached it; its own finding moves into the section so the verdict and the dead chain read together.

The flags identifying the machinery (is_local_date_table, is_template_date_table, is_private) are display-only metadata and never touch either verdict's inputs.

The inverted question: broken bindings (issue #60)

Where the liveness question is "does anything keep this alive?", the broken-bindings records (broken_bindings, built in graph/broken.rs) ask the opposite: did a written report binding name anything at all? During root construction every binding's resolution is classified — resolved, resolved only to its qualifying table (the field is gone), or resolved to nothing — and the misses become BrokenBindings carrying the binding's full provenance (BindingEdge), the written FieldTarget, and a BrokenReason. A second, standalone pass walks every DAX expression the model and the reports own and records the artifacts whose own field references bind to nothing; a binding that resolves onto such an artifact stays a root but reports the inherited breakage (bound_artifact_broken, the seam issue #84 will promote to its own finding kind).

The conservatism rule inverts: a liveness claim may over-keep, a breakage claim must under-claim — a false "broken" is itself a breakage claim. So the classification never flags what the machinery might resolve: KPI-suffixed measure variants (… Goal, … Status, … Trend, … Value) resolve when the base measure exists; a variation-flavored hierarchy the variation machinery cannot resolve stays silent (rather than risk calling auto date/time serialization drift a breakage); a same-named hierarchy or calculation item behind a stale column qualifier resolves; a qualified field miss on a calculated table resolves when the name is lexically visible in that table's partition expression — its string literals, the constructor defaults, the columns it writes, and every column of the tables it references, the over-approximation of an output schema the TMDL never declares; and a Written reference the parser could not structure never flags. The qualifying-table fallback keeps its liveness role unchanged — the records ride along, they never move a node in or out of the unused set.

Determinism

Node and edge construction follows model and report source order; identical (from, to, provenance) triples dedupe; unused_objects sorts by ObjectId (folded names, so case never changes the order), and broken_bindings sorts by where the binding lives (report, page, visual, bookmark, layout), then the written target, then the reason. Two runs over the same input produce the same output, byte for byte.