Repository navigation
Fixes 36357: Hex data models and column-level lineage from project SQL cells - #36371
Conversation
Each Hex project now gets one HexDataModel with a struct column per SQL
cell, holding the columns that cell outputs. Column lineage runs from the
warehouse table columns to those cells, then data model -> dashboard.
The SQL comes from the project export (falling back to the cells API),
which names each cell's result variable, so chained SQL cells are inlined
as CTEs the way Hex runs them and trace back to the warehouse. Input
parameters ({{ x }}) parse as values, and sqlglot resolves each output
column with table schemas from OpenMetadata. The warehouse query history
path stays as the fallback when no cells can be read.
The Hex client retries 429 responses, as Hex rate-limits each endpoint
to about 30 requests per minute.
A project whose cells or data connections can't be read keeps the data model an earlier run created, marked as seen and still linked to its dashboard, instead of being deleted over a failed API call. A failed data connections read is retried for the next project rather than cached. A table reference matching several tables (the same schema.table in two services, or a bare name) now resolves to none instead of the first search hit.
…ailures - A table reference resolves only when exactly one table matches across all db service prefixes, and only with a schema: from the SQL, the Hex connection's defaults (now read from the data connections API) or the prefix. A bare name is no longer matched in any service. - Tables the warehouse query history found but no cell resolved still link to the dashboard, instead of cells replacing that lineage. - The cells API stands in for the export only when the export is not allowed (403/404). A transient export failure, or a failed table search, now leaves the project's data model as it is rather than rewriting it from incomplete cells. - The data model lookup for the dashboard link can no longer fail the dashboard, and an unread project's model is marked as seen without one.
… names - Columns resolve case-insensitively, quoted or not, so Snowflake's "REGION" and Postgres' "createdAt" match the table's columns instead of losing their lineage (a table-qualified one used to drop the whole cell). A failed qualify now keeps the columns, without lineage. A column passed straight through keeps its table's casing. - A cell is stored for the cells chained after it once its references name the tables found, so tables it found through its connection's defaults still resolve from a dataframe cell. - Column names are made unique after they are made valid, so two outputs can no longer collapse into one rejected name. - Data connections the token can't read (403/404) are treated as none known, rather than as a transient failure that would keep every data model unchanged forever.
- A table reference a cell could not resolve now carries the database and schema it was searched under, so a chained cell inlining it, which has no connection defaults of its own, can't match a table elsewhere. - A quoted alias keeps the spelling it was given in the data model.
- A table reference a cell could not resolve is marked, and a chained cell inlining it leaves it unresolved, rather than searching it again without that cell's connection defaults (which could match a table in another database through a prefix). - A warehouse cell only inlines upstream cells on its own connection, as Hex does; SQL on dataframes can read any cell's result. - A UNION branch selecting * from an unknown table no longer drops the cell: sqlglot's IndexError there now keeps the columns without lineage.
…odels - The names a dataframe SQL cell reads are dataframes, so they are never looked up as warehouse tables; such a cell reaches the warehouse only through the cells it chains to. - Outputs sharing a name (o.id, c.id, or * over a join) are all kept, the repeats suffixed, instead of all but the first being dropped. - dataModelFilterPattern applies to Hex data models.
- An inlined upstream cell keeps its own WITH, and table references are found scope by scope, so a CTE defined inside an upstream cell can no longer capture a warehouse table of the same name the downstream cell reads. - Each cell's column group is named by its stable id (the export's cellId, the cells API's staticId) with the label as its display name, so a renamed or reordered cell keeps its columns' descriptions, tags and lineage. - Lineage never targets a data model excluded by dataModelFilterPattern, which this run deletes.
- Add ADR:2026-10-27-hex-project-is-one-data-model-with-a-column-group-per-cell-id:
the data model and its column groups are named by project and cell
id, tables resolve only uniquely and with a schema, and a failed read
keeps the existing data model.
- A cell over 10,000 nodes isn't stored for inlining, so a notebook where
each cell joins the ones before it no longer grows exponentially; the
cells chained to it keep their columns, without lineage.
- Inlining keeps WITH RECURSIVE, so a recursive CTE isn't looked up as
a table.
- Only the first branch of a Jinja {% if %} is kept, so the SQL parses.
- A bare * over a join with a dataframe is spelled out per table, so the
known table's columns are kept.
The kept-model link now applies dataModelFilterPattern like the data model and lineage stages, so a dashboard is never pointed at a data model this run deletes.
❌ PR checklist incompleteThis PR cannot be merged until the following are addressed on its linked issue:
The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically. Maintainers can bypass this check by adding the |
✅ Generated Sources Auto-UpdatedThe generated TypeScript types ( |
…caps - A t.* is dropped only where t names an unknown table in that SELECT's own scope, so an alias reused across CTEs no longer loses the known table's columns (and their lineage). - The table lookup memo is an LRUCache(1000), and a project keeps at most 100,000 nodes of cell queries for chaining; cells past it are stored as unresolvable rather than read as warehouse tables. - The decision record now also states the child column casing, the inlining caps and the 429 retry budget.
🚦 Removed from the merge queue —
|
Keep the data model ingested without a link when the Hex workspace id can't be fetched, as main now does for the dashboard.
Code Review ✅ Approved 2 closed / 2 findings🟡 Medium risk · Adding Hex project data models and chained SQL column lineage changes ingested metadata and could misattribute or omit lineage when resolution is incomplete. Adds a Hex data model per project with a column group per SQL cell, plus column-level lineage from warehouse tables through chained cells to the dashboard. The unrecorded decisions around the 429 retry budget, inline node cap and column casing were resolved, and the passthrough struct tag/profile copy was closed without a fix. No open issues found. ✅ 2 closed✅ Quality: Unrecorded decisions: 429 retry budget, inline node cap, column casing
Closed: Bug: Passthrough struct children copy the table's tags, profile and descriptions
VerificationRules No rules evaluated Functional validation Not enabledAuto-approval Not enabledFor more verification details, comment OptionsDisplay: compact → Showing compact verification results. Comment with these commands to change the behavior for this request:
Was this helpful? React with 👍 / 👎 | Powered by Gitar — free for open source |
|
|
🚦 Removed from the merge queue —
|
🚦 Removed from the merge queue —
|
…odel in the generated types Matches the resolution on main (#36371) of the data model sourceUrl against the workspace-scoped Hex URLs, which return None when the workspace id can't be read. The generated TypeScript enum value is the one #36371 generated on main; the type-generation workflow fails on 2.0 so it never added it here.
Matches the resolution on main (#36371) of the data model sourceUrl against the workspace-scoped Hex URLs, which return None when the workspace id can't be read.
…(2.0) (#36388) * Fixes 36357: Hex data models and column-level lineage from project SQL cells (2.0) Backport of #36371 to 2.0, squashed from 33b47d3, 16ad476, 22d96d4, 6af0b43, 791ff4a, c42a857, cacf30b, c5d461c, 61debe6, 05f7d5c and d996669. Adapted to 2.0: - es_search_from_fqn has no raise_on_error here and returns None for a failed search, so the Hex source runs the FQN search itself and treats a search that didn't answer as an error, not as "no such table". - The project's tag handling (tag_utils) is kept as it is on 2.0. - The decision record is not added: 2.0 has no docs/decisions. The code cites ADR:2026-10-27-hex-project-is-one-data-model-with-a-column-group-per-cell-id on main. * Hex: keep models without data connections, keep broken cells' groups - Data connections the token can't read raise HexAccessDeniedError and are asked for once per run. Without them an existing data model is left as it is instead of being rebuilt without dialects and connection defaults; only a project without one gets one. - A cell whose SQL fails to parse keeps its column group from the existing data model, with what users curated on it. - A column name is parsed out of the lineage leaf rather than split on dots, so a quoted "customer.id" is one name. - A column passed straight through keeps its table column's array element type, display type and nested fields. (cherry picked from commit a6b8920) * Hex (2.0): a failed read of a table the search found keeps the data model The 2.0 FQN search reads each table it finds; an error there now counts as a failed search, so the project's existing data model is kept rather than rebuilt without that cell. * Hex: keep a kept data model linked, copy only the type of passed-through columns - A data model kept because its existing groups couldn't be read stays linked to the dashboard: the data model stage records what it kept and the dashboard stage links it, instead of only when the cells couldn't be read. - A passed-through struct's fields copy only the table column's type, not its description, tags, constraint or profile. (cherry picked from commit c02d176) * Hex: a data model without a dashboard URL is still ingested; HexDataModel in the generated types Matches the resolution on main (#36371) of the data model sourceUrl against the workspace-scoped Hex URLs, which return None when the workspace id can't be read. The generated TypeScript enum value is the one #36371 generated on main; the type-generation workflow fails on 2.0 so it never added it here. * Hex: cells reading a broken cell keep their data model groups A cell whose SQL fails keeps its existing group, but the cells reading its result lost theirs: dataframe SQL over it was dropped and a warehouse cell searched its name as a table, coming back with UNKNOWN columns. A cell reading a failed cell now fails too, down the chain, so each keeps its group. (cherry picked from commit ea7e122) * Hex: add the data model decision record the code cites 2.0 now has docs/decisions/, so the record #36371 added on main (with its v2 amendment from #36541) comes along instead of leaving the ADR citation in hex/metadata.py dangling.
…(2.1) (#36387) * Hex: ingest data models and column lineage from project SQL cells Each Hex project now gets one HexDataModel with a struct column per SQL cell, holding the columns that cell outputs. Column lineage runs from the warehouse table columns to those cells, then data model -> dashboard. The SQL comes from the project export (falling back to the cells API), which names each cell's result variable, so chained SQL cells are inlined as CTEs the way Hex runs them and trace back to the warehouse. Input parameters ({{ x }}) parse as values, and sqlglot resolves each output column with table schemas from OpenMetadata. The warehouse query history path stays as the fallback when no cells can be read. The Hex client retries 429 responses, as Hex rate-limits each endpoint to about 30 requests per minute. (cherry picked from commit 33b47d3) * Hex: keep data models on failed API reads, never guess a table A project whose cells or data connections can't be read keeps the data model an earlier run created, marked as seen and still linked to its dashboard, instead of being deleted over a failed API call. A failed data connections read is retried for the next project rather than cached. A table reference matching several tables (the same schema.table in two services, or a bare name) now resolves to none instead of the first search hit. (cherry picked from commit 16ad476) * Hex: resolve tables only when unambiguous, keep models on transient failures - A table reference resolves only when exactly one table matches across all db service prefixes, and only with a schema: from the SQL, the Hex connection's defaults (now read from the data connections API) or the prefix. A bare name is no longer matched in any service. - Tables the warehouse query history found but no cell resolved still link to the dashboard, instead of cells replacing that lineage. - The cells API stands in for the export only when the export is not allowed (403/404). A transient export failure, or a failed table search, now leaves the project's data model as it is rather than rewriting it from incomplete cells. - The data model lookup for the dashboard link can no longer fail the dashboard, and an unread project's model is marked as seen without one. (cherry picked from commit 22d96d4) * Hex: resolve quoted identifiers, keep chained defaults, unique column names - Columns resolve case-insensitively, quoted or not, so Snowflake's "REGION" and Postgres' "createdAt" match the table's columns instead of losing their lineage (a table-qualified one used to drop the whole cell). A failed qualify now keeps the columns, without lineage. A column passed straight through keeps its table's casing. - A cell is stored for the cells chained after it once its references name the tables found, so tables it found through its connection's defaults still resolve from a dataframe cell. - Column names are made unique after they are made valid, so two outputs can no longer collapse into one rejected name. - Data connections the token can't read (403/404) are treated as none known, rather than as a transient failure that would keep every data model unchanged forever. (cherry picked from commit 6af0b43) * Hex: don't widen an unresolved upstream reference, keep quoted aliases - A table reference a cell could not resolve now carries the database and schema it was searched under, so a chained cell inlining it, which has no connection defaults of its own, can't match a table elsewhere. - A quoted alias keeps the spelling it was given in the data model. (cherry picked from commit 791ff4a) * Hex: never re-resolve an unresolved upstream table, chain per connection - A table reference a cell could not resolve is marked, and a chained cell inlining it leaves it unresolved, rather than searching it again without that cell's connection defaults (which could match a table in another database through a prefix). - A warehouse cell only inlines upstream cells on its own connection, as Hex does; SQL on dataframes can read any cell's result. - A UNION branch selecting * from an unknown table no longer drops the cell: sqlglot's IndexError there now keeps the columns without lineage. (cherry picked from commit c42a857) * Hex: dataframe names aren't tables, keep same-named outputs, filter models - The names a dataframe SQL cell reads are dataframes, so they are never looked up as warehouse tables; such a cell reaches the warehouse only through the cells it chains to. - Outputs sharing a name (o.id, c.id, or * over a join) are all kept, the repeats suffixed, instead of all but the first being dropped. - dataModelFilterPattern applies to Hex data models. (cherry picked from commit cacf30b) * Hex: nest inlined cells, name column groups by cell id, honor the filter - An inlined upstream cell keeps its own WITH, and table references are found scope by scope, so a CTE defined inside an upstream cell can no longer capture a warehouse table of the same name the downstream cell reads. - Each cell's column group is named by its stable id (the export's cellId, the cells API's staticId) with the label as its display name, so a renamed or reordered cell keeps its columns' descriptions, tags and lineage. - Lineage never targets a data model excluded by dataModelFilterPattern, which this run deletes. (cherry picked from commit c5d461c) * Hex: record the data model naming, bound chained SQL, more SQL shapes - Add ADR:2026-10-27-hex-project-is-one-data-model-with-a-column-group-per-cell-id: the data model and its column groups are named by project and cell id, tables resolve only uniquely and with a schema, and a failed read keeps the existing data model. - A cell over 10,000 nodes isn't stored for inlining, so a notebook where each cell joins the ones before it no longer grows exponentially; the cells chained to it keep their columns, without lineage. - Inlining keeps WITH RECURSIVE, so a recursive CTE isn't looked up as a table. - Only the first branch of a Jinja {% if %} is kept, so the SQL parses. - A bare * over a join with a dataframe is spelled out per table, so the known table's columns are kept. (cherry picked from commit 61debe6) * Hex: don't keep a filtered-out data model linked when Hex can't be read The kept-model link now applies dataModelFilterPattern like the data model and lineage stages, so a dashboard is never pointed at a data model this run deletes. (cherry picked from commit 05f7d5c) * Hex: drop unknown stars per scope, bound per-project lookups, record caps - A t.* is dropped only where t names an unknown table in that SELECT's own scope, so an alias reused across CTEs no longer loses the known table's columns (and their lineage). - The table lookup memo is an LRUCache(1000), and a project keeps at most 100,000 nodes of cell queries for chaining; cells past it are stored as unresolvable rather than read as warehouse tables. - The decision record now also states the child column casing, the inlining caps and the 429 retry budget. (cherry picked from commit d996669) * Update generated TypeScript types and dereferenced schemas * Hex: keep models without data connections, keep broken cells' groups - Data connections the token can't read raise HexAccessDeniedError and are asked for once per run. Without them an existing data model is left as it is instead of being rebuilt without dialects and connection defaults; only a project without one gets one. - A cell whose SQL fails to parse keeps its column group from the existing data model, with what users curated on it. - A column name is parsed out of the lineage leaf rather than split on dots, so a quoted "customer.id" is one name. - A column passed straight through keeps its table column's array element type, display type and nested fields. (cherry picked from commit a6b8920) * Hex: keep a kept data model linked, copy only the type of passed-through columns - A data model kept because its existing groups couldn't be read stays linked to the dashboard: the data model stage records what it kept and the dashboard stage links it, instead of only when the cells couldn't be read. - A passed-through struct's fields copy only the table column's type, not its description, tags, constraint or profile. (cherry picked from commit c02d176) * Hex: a data model without a dashboard URL is still ingested Matches the resolution on main (#36371) of the data model sourceUrl against the workspace-scoped Hex URLs, which return None when the workspace id can't be read. * Hex: cells reading a broken cell keep their data model groups A cell whose SQL fails keeps its existing group, but the cells reading its result lost theirs: dataframe SQL over it was dropped and a warehouse cell searched its name as a table, coming back with UNKNOWN columns. A cell reading a failed cell now fails too, down the chain, so each keeps its group. (cherry picked from commit ea7e122) --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>



Describe your changes:
Fixes #36357
The Hex connector ingested each project as a dashboard and nothing more: no data models, no column lineage, and table → dashboard lineage only from the warehouse query history (needs warehouse credentials, 7-day window). This adds one
HexDataModelper project, with the columns each SQL cell outputs, and column-level lineage from the warehouse table columns to those cells, then data model → dashboard, the way Power BI / Tableau / Sigma do.Type of change:
High-level design:
Source of the SQL. Each project's SQL cells come from
POST /v1/projects/export(YAML). The export is needed because only it names each cell's result variable: in real Hex notebooks most dashboard-facing cells are chained SQL that query another cell's result (FROM base_opptys) rather than a warehouse table.GET /v1/cellsstands in only when the export isn't allowed (403/404). The warehouse query-history lineage stays, and also adds any table that no cell resolved.Columns and lineage (
hex/cell_lineage.py). A Hex SQL cell is a plain SELECT with no target table, soLineageParserreturns no column lineage for it. Instead:GET /v1/data-connections, which also gives the connection's default database/schema; DuckDB for dataframe SQL). Jinja input parameters ({{ x }}) becomeNULLso parameterized cells still parse.WITH, so its CTE names can't capture the downstream cell's tables), which is how Hex runs chained SQL, so every output column traces back to the warehouse through any number of cells. A warehouse cell chains only to cells on its own connection, as in Hex. Dataframe SQL cells are kept when they chain off warehouse cells; the names they read themselves are dataframes, never looked up as warehouse tables. A table an upstream cell couldn't resolve stays unresolved downstream.SELECT *and unqualified columns), and walksqlglot.lineageper output column. Columns resolve case-insensitively, quoted or not, so Snowflake"REGION"and Postgres"createdAt"both match. As a result output names are lower-cased, except a column passed straight through, which keeps its table's casing. A type carries over only when every hop is a bare column.Entities (
hex/metadata.py). OneDashboardDataModelper project (<service>.model.<projectId>), with oneSTRUCTcolumn per SQL cell holding its output columns, so names never clash across cells. Each group is named by the cell's stable id (the export'scellId, which is the cells API'sstaticId), with the cell label as itsdisplayName, so renaming or reordering cells keeps the curated descriptions, tags and lineage. Edges: table → data model (withcolumnsLineageto<model>.<cell>.<column>), data model → dashboard. The server already validates nested child columns as lineage endpoints. WithincludeDataModelsoff, the tables link straight to the dashboard.Safety.
Schema.
HexDataModelis added todataModelTypeindashboardDataModel.json. No migration is needed: it only adds an enum value, and no stored entity changes.Alternatives rejected. The cells API alone has no result-variable names, so lineage stops at the first chained cell. The warehouse query history needs warehouse credentials, a lookback window, and misses cached results. A flat column list per project (DataHub's
InputFields) has no equivalent in OM: column lineage must end on a child of the target entity.Tests:
Use cases covered
SELECT *expansion, CTEs, joins, and stars over Python dataframes.includeDataModelsoff → table → dashboard.UNIONwith a*over an unknown table. Outputs sharing a name (*over a join) all kept. Column names sanitized and kept unique.dataModelFilterPatternhonored. A cell's CTE never shadows another cell's table. A renamed cell keeps its column group.WITH RECURSIVE, Jinja{% if %}/{% else %}, a bare*over a join with a dataframe. Notebooks with long chains stay bounded (a cell over 10,000 nodes isn't inlined).Unit tests
ingestion/tests/unit/topology/dashboard/test_hex_cell_lineage.py(new),test_hex.py.PYTHONPATH=src python -m pytest tests/unit/topology/dashboard/test_hex*.py→ 146 passed (test_hex.py,test_hex_cell_lineage.py,test_hex_client.py,test_hex_ingestion_flow.py,test_hex_lineage.py).cell_lineage.py94%,client.py90% (the rest is the untouchedtest_project),metadata.py90%,models.py100%.ruff check/ruff format --check,check_ruff_suppressions.py --check, andbasedpyrightonhex/(0 errors).Backend integration tests
Local Java test run: PASSED
33b47d3c0db1(baseorigin/main); later commits change only ingestion Python, a lint baseline and a decision recordIngestion integration tests
tests/integration/has no Hex directory). The connector was verified end to end against a live Hex workspace and a local server instead (below).Playwright (UI) tests
Manual testing performed
mvn -DskipTests clean package) and started./docker/run_local_docker.sh -m ui -d mysql -s true -i false;/api/v1/system/version→2.0.0-SNAPSHOT.B2B_DEMO_DATA.PRODtables that Hex's demo projects read (Hex's demo warehouse credentials aren't exposed, so the tables were created through the API).HexDataModel. Sales Revenue Dashboard has 7 cell groups, displayed by label (Opportunities, Won Opportunities, Current Quarter Pipeline, Join Opportunities and Targets, Quarterly ARR Progress, S0/S1 Pipeline), and the server accepted all 74 column links (none filtered as invalid). Chained columns resolve across cells, e.g.Quarterly ARR Progress.qtd_actual(SUM(ARR), 3 cells downstream) ←dim_opportunities.arr.UI screen recording:
Not applicable: ingestion connector change with no UI code. The new entities render through the existing data model and lineage pages (checked by hand, step 5 above).
Checklist:
I have read the CONTRIBUTING document.
My PR title is
Fixes <issue-number>: <short explanation>My PR is linked to a GitHub issue via
Fixes #<issue-number>above.I have commented on my code, particularly in hard-to-understand areas.
For JSON Schema changes: I updated the migration scripts or explained why it is not needed.
For a UI feature or feature task: as part of PR creation, I ran the PR build in Docker with sample data and embedded a verified GitHub-hosted recording plus build/health evidence above (or explained why this requirement is not applicable).
I have added tests (unit / integration / Playwright as applicable) and listed them above.
Self-review: I reviewed the final diff against
REVIEW.md(thepr-checklistskill runs it) and fixed every 🔴 finding, or answered it on the line below.Reviewed
05f7d5cf770: nine fresh-context passes againstREVIEW.md; bot review findings then fixed ind99666934ae. Every 🔴 was fixed with a test that fails without the fix. The last delta is the reviewer's own suggested fix, with its test. Accepted rather than fixed (🟡, non-blocking): unaliased output names are lower-cased, a consequence of case-insensitive column resolution. WithoverrideLineage, a transient Hex failure can drop a kept data model's model → dashboard edge until the next good run.get_projectsreturning a partial list on a failed page is pre-existing onmain.Decision check: if this PR decides something the next contributor could undo — a schema field, REST path or format another component or repo must agree with, a new default or limit, an ordering that must hold, or a rule stated only in this description — it adds a record under
docs/decisions/(see itsREADME.md). This description is gone fromgit logafter the squash-merge.Adds
docs/decisions/2026-10-27-hex-project-is-one-data-model-with-a-column-group-per-cell-id.md.The issue properly describes why the new feature is needed, what's the goal, and how we are building it.
I have updated the documentation.
I have added tests around the new logic.


Confidence Score: 5/5
The PR appears safe to merge, with a non-blocking loss of nested type details.Findings
Summary
Adds one
HexDataModelper Hex project, with stable column groups for SQL cells and warehouse column lineage through chained cells.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR Export[Hex project SQL cells] --> Resolve[Resolve tables and chained cells] Resolve --> Model[HexDataModel with one group per cell] Tables[Warehouse table columns] -->|Column lineage| Model Model --> Dashboard[Hex dashboard] History[Additional query-history tables] --> DashboardReviews (6) · Last reviewed commit: "Hex: keep a kept data model linked, copy..." · Reviewed by Greptile