diff --git a/docs/published/handbook/engineering/data-warehouse.md b/docs/published/handbook/engineering/data-warehouse.md
index c4ec0d3436b5..eed5642fb1f9 100644
--- a/docs/published/handbook/engineering/data-warehouse.md
+++ b/docs/published/handbook/engineering/data-warehouse.md
@@ -236,3 +236,18 @@ You'll need to install MS SQL drivers for the PostHog app to connect to a MS SQL
```text
symbol not found in flat namespace '_bcp_batch'
```
+
+## Connected fields in BI mode
+
+Below Dimensions and Measures, **Connections** lists the selected table's linked tables and views.
+Expand a connection to load its dimensions, measures, and nested connections. Inside connections,
+fields and further links appear without section headings.
+
+Drag connected fields onto a shelf, double-click them, or press Enter or Space to add dimensions to Rows and measures to Values.
+Measures are aggregated automatically. The worksheet keeps its original source table and uses the full
+connection path in queries and shelf labels, such as `person.company.name`.
+Connections expand on demand, including repeated links to the same table. Search filters the fields
+inside expanded connections, and a failed field load has a Retry button.
+Aliases load any intermediate tables automatically. Loading and failed connections stay visible during search.
+Virtual connections expose the field names provided by the existing schema as dimensions. The schema does
+not include their field types or nested link definitions, so these connections do not infer measures or further links.
diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml
index 08ba17246fe0..1ffeb824593d 100644
--- a/frontend/snapshots.yml
+++ b/frontend/snapshots.yml
@@ -9470,6 +9470,10 @@ snapshots:
hash: v1.k794b7964.25fd8a22fa490bf3e3e600a984b131b28202b8172e46e1a35e090694dfe63e2f.UBgi2bw3KLPn9F1RDJIVKYgfFlzVDD2yZj4iUPufRVA
scenes-app-data-warehouse-sql-editor--bi-calculated-measure-editor--light:
hash: v1.k794b7964.66016e766d5d3b78aae28be29d15e9bbf7ebb60b48de6d488eb7b10ea4676afd.wpa1Nkk9kYDByTPyA-MjGrtArnLDfzP4S6zkO34CMfo
+ scenes-app-data-warehouse-sql-editor--bi-connections--dark:
+ hash: v1.k794b7964.1f2ab2237c64512591fa64ede2d01c957272510b58e8d280d03f972947127f18.FGHAY9evifk4UKdQC7B9nSpLfbdJH9G26TeeACebtLY
+ scenes-app-data-warehouse-sql-editor--bi-connections--light:
+ hash: v1.k794b7964.6cc3f998d8be9bf398eaaa215c3f8eaa9dc6557a2008361d3ba9bf3bf9d91da2.ddofcjJWgAQ_QZYP6WkNgJIp_TeW5k1Wmc_NTG1M7ZQ
scenes-app-data-warehouse-sql-editor--bi-data-source-picker--dark:
hash: v1.k794b7964.5a7a0a5291dae3a67320c33fe0d7e087ab35dcd5738ee99f101735cd7fe6895d.v84-mZ0JtjVV_28syxCq0lC0DPMk7Z4LPSKq_seARBg
scenes-app-data-warehouse-sql-editor--bi-data-source-picker--light:
@@ -9483,9 +9487,9 @@ snapshots:
scenes-app-data-warehouse-sql-editor--bi-quick-filters--light:
hash: v1.k794b7964.7d055020d8b9cdc001efc8b5ca146f2d22e7c916af54b7361f2d8f61d59b940f.11bvglysenXe98m_innE4VvTdCUKMhRuSS62QGmFscM
scenes-app-data-warehouse-sql-editor--bi-quick-filters-narrow--dark:
- hash: v1.k794b7964.3a3d179dc8ec4fed037e1e0737ddad5dea4f828b262e35d07ae5c0dcfec1447d.dqy-xY-ApF2QSGtDMf_HIMrtyrPbFAnhhWHB8cmi4M8
+ hash: v1.k794b7964.9438ec9789fe83709762909669ffa2b2377eab421066004a257c7467ea941ad1.zz_SahLmEDK9GozyYfPrB5ydS01sT-n0L9kpHjIPCd0
scenes-app-data-warehouse-sql-editor--bi-quick-filters-narrow--light:
- hash: v1.k794b7964.46ca9e07669ba124d6d051b6b0cf1dbbf30b441533a9eaa064c3492577893e93.KhY5WMXImePUoV5bH-nrNztp1axD08hcOl4kfH-uaYQ
+ hash: v1.k794b7964.27d42f80122f06e2159d402c8887260e3a057bae29102b96b66f6de97c4116c3.04u1T8VmDQZWOP90QHr9sO19BElmuaemVAxK-394fHc
scenes-app-data-warehouse-sql-editor--edited-insight--dark:
hash: v1.k794b7964.94eba9d351ec4c6b649fc4b09f5d4a69fed2995448c0cc1dac5d63d3df0ad20c.U6hnpHxuk9RKFoPU8bc536a9dTuzluwHE0pF4J0nPYc
scenes-app-data-warehouse-sql-editor--edited-insight--light:
diff --git a/frontend/src/scenes/data-management/database/DatabaseTable.tsx b/frontend/src/scenes/data-management/database/DatabaseTable.tsx
index 33e764b3aeb6..7be7afc87c5a 100644
--- a/frontend/src/scenes/data-management/database/DatabaseTable.tsx
+++ b/frontend/src/scenes/data-management/database/DatabaseTable.tsx
@@ -117,8 +117,8 @@ export function DatabaseTable({ table, tables, inEditSchemaMode, schemaOnChange
title: 'Column',
key: 'key',
dataIndex: 'name',
- render: function RenderColumn(column) {
- return {column}
+ render: function RenderColumn(_, { name }) {
+ return {name}
},
},
{
diff --git a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx
index 0cca798ddb6f..91b4f655d22f 100644
--- a/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/OutputPane.tsx
@@ -1348,7 +1348,7 @@ const Content = ({
if (!response && !responseLoading && !insightLoading) {
return (
diff --git a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx
index 9d12de4c2811..53c8160cab3b 100644
--- a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx
@@ -867,3 +867,104 @@ export const JevEarlySelection: Story = {
parameters: chartExperimentParameters(true, 100, 3000),
decorators: [withAIConsent(true)],
}
+const BI_CONNECTIONS_CONFIG: BIConfig = {
+ ...BI_WORKSHEET_CONFIG,
+ chartType: ChartDisplayType.ActionsTable,
+ rows: [],
+ columns: [],
+ filters: [],
+}
+
+export const BIConnections: Story = {
+ ...BIModeWorksheet,
+ parameters: {
+ ...BIModeWorksheet.parameters,
+ pageUrl: `${urls.sqlEditor()}#${new URLSearchParams({
+ q: buildBIQuery(BI_CONNECTIONS_CONFIG)?.query ?? '',
+ mode: 'bi',
+ bi: JSON.stringify(BI_CONNECTIONS_CONFIG),
+ })}`,
+ testOptions: {
+ waitForSelector: '[data-attr="bi-editor-connection-fields"] [data-attr="bi-editor-connection-fields"]',
+ viewport: { width: 1280, height: 900 },
+ },
+ msw: {
+ mocks: {
+ ...BIModeWorksheet.parameters?.msw.mocks,
+ post: {
+ ...BIModeWorksheet.parameters?.msw.mocks.post,
+ '/api/environments/:team_id/query/DatabaseSchemaQuery/': {
+ tables: {
+ events: {
+ id: 'events',
+ name: 'events',
+ type: 'posthog',
+ fields: {
+ event: BI_EVENTS_FIELDS.event,
+ revenue: BI_EVENTS_FIELDS.revenue,
+ person: {
+ name: 'person',
+ type: 'lazy_table',
+ table: 'persons',
+ schema_valid: true,
+ hogql_value: 'person',
+ },
+ },
+ },
+ persons: {
+ id: 'persons',
+ name: 'persons',
+ type: 'posthog',
+ fields: {
+ email: { name: 'email', type: 'string', schema_valid: true, hogql_value: 'email' },
+ lifetime_value: {
+ name: 'lifetime_value',
+ type: 'float',
+ schema_valid: true,
+ hogql_value: 'lifetime_value',
+ },
+ company: {
+ name: 'company',
+ type: 'lazy_table',
+ table: 'companies',
+ schema_valid: true,
+ hogql_value: 'company',
+ },
+ },
+ },
+ companies: {
+ id: 'companies',
+ name: 'companies',
+ type: 'data_warehouse',
+ fields: {
+ name: { name: 'name', type: 'string', schema_valid: true, hogql_value: 'name' },
+ annual_revenue: {
+ name: 'annual_revenue',
+ type: 'decimal',
+ schema_valid: true,
+ hogql_value: 'annual_revenue',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ await waitFor(() => expect(canvasElement.querySelector('[data-attr="bi-editor-data-source"]')).toBeVisible(), {
+ timeout: 15000,
+ })
+ await userEvent.click(canvas.getByRole('button', { name: 'SQL' }))
+ await userEvent.click(canvas.getByRole('button', { name: 'BI' }))
+ const autoUpdate = canvasElement.querySelector('[data-attr="bi-editor-auto-update"]')!
+ if (autoUpdate.getAttribute('aria-checked') === 'true') {
+ await userEvent.click(autoUpdate)
+ }
+ await userEvent.click(await canvas.findByRole('button', { name: 'person' }))
+ await userEvent.click(await canvas.findByRole('button', { name: 'person.company' }))
+ await waitFor(() => expect(canvas.getByText('annual_revenue')).toBeVisible())
+ },
+}
diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts
index f31751edb1da..624de30e2b97 100644
--- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts
+++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts
@@ -298,12 +298,17 @@ export interface BIDataPaneFields {
measures: BIField[]
}
-export function getBIDataPaneFields(table: DatabaseSchemaTable | undefined, source: BIDataSource): BIDataPaneFields {
+export function getBIDataPaneFields(
+ table: Pick | undefined,
+ source: BIDataSource,
+ path: string[] = []
+): BIDataPaneFields {
const fields = Object.values(table?.fields ?? {})
.filter((field) => DATA_PANE_FIELD_TYPES.has(field.type))
.map((field): BIField => {
- const expression = escapeDottedHogQLIdentifier(field.name)
- return { id: getBIFieldId(source, expression), name: field.name, expression, type: field.type, source }
+ const name = [...path, field.name].join('.')
+ const expression = escapeDottedHogQLIdentifier(name)
+ return { id: getBIFieldId(source, expression), name, expression, type: field.type, source }
})
.sort((first, second) => first.name.localeCompare(second.name))
diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx
index b72695758f9e..7dcbcbe5a5bf 100644
--- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx
+++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx
@@ -1,93 +1,15 @@
import { useActions, useValues } from 'kea'
-import { IconCalendar, IconPlus } from '@posthog/icons'
+import { IconPlus } from '@posthog/icons'
import { LemonButton, LemonInput, Spinner } from '@posthog/lemon-ui'
-import { cn } from 'lib/utils/css-classes'
-
+import { BIConnections } from 'products/data_warehouse/frontend/bi/BIConnections'
+import { BIDataPaneSection } from 'products/data_warehouse/frontend/bi/BIDataPaneSection'
import { BIDataSourcePicker } from 'products/data_warehouse/frontend/bi/BIDataSourcePicker'
import { editorSizingLogic } from '../../editorSizingLogic'
import { queryDatabaseLogic } from '../../sidebar/queryDatabaseLogic'
import { biEditorLogic } from '../biEditorLogic'
-import { BIField, BI_FIELD_DRAG_MIME_TYPE, getBIDropTarget, serializeBIField } from '../biEditorTypes'
-
-function FieldTypeGlyph({ field, measure }: { field: BIField; measure: boolean }): JSX.Element {
- const glyph =
- field.type === 'date' || field.type === 'datetime' ? (
-
- ) : measure || ['integer', 'float', 'decimal'].includes(field.type) ? (
- '#'
- ) : field.type === 'boolean' ? (
- 'T|F'
- ) : field.type === 'json' || field.type === 'array' ? (
- '{ }'
- ) : (
- 'Abc'
- )
- return (
-
- {glyph}
-
- )
-}
-
-function DataPaneSection({
- title,
- fields,
- measure,
- emptyText,
-}: {
- title: string
- fields: BIField[]
- measure: boolean
- emptyText: string
-}): JSX.Element {
- const { addFieldToShelf } = useActions(biEditorLogic)
-
- const addField = (field: BIField): void => {
- const target = getBIDropTarget(field, 'rows')
- addFieldToShelf(target.field, target.shelf)
- }
-
- return (
-
-
{title}
- {fields.length === 0 ?
{emptyText} : null}
- {fields.map((field) => (
-
{
- event.dataTransfer.effectAllowed = 'copy'
- event.dataTransfer.setData(BI_FIELD_DRAG_MIME_TYPE, serializeBIField(field))
- }}
- onDoubleClick={() => addField(field)}
- onKeyDown={(event) => {
- if (event.key === 'Enter') {
- addField(field)
- }
- }}
- data-attr={measure ? 'bi-editor-data-pane-measure' : 'bi-editor-data-pane-dimension'}
- >
-
- {field.name}
-
- ))}
-
- )
-}
/** Lists the selected table's fields as dimensions and measures, ready to drag onto shelves. */
export function BIDataPane(): JSX.Element {
@@ -161,20 +83,18 @@ export function BIDataPane(): JSX.Element {
) : !hasFields ? (
-
- No fields found. Drag columns from the database tree instead.
-
+ No dimensions or measures on this table.
) : !hasMatches ? (
- No matching fields
+ No matching fields in this table
) : (
<>
-
-
>
)}
+ {config.source && !dataPaneFieldsError ? : null}
{
return chain.map((segment) => String(segment)).join('.')
}
-const resolveFieldTraverserTarget = (
+export const resolveFieldTraverserTarget = (
tableName: string,
field: DatabaseSchemaField,
tableLookup?: TableLookup,
- visitedChains: Set = new Set()
+ visitedChains: Set = new Set(),
+ onUnloadedTable?: (tableName: string) => void
): DatabaseSchemaField | null => {
if (!field.chain || !tableLookup) {
return null
}
+ const traversalKey = JSON.stringify([tableName, field.chain])
+ if (visitedChains.has(traversalKey)) {
+ return null
+ }
+ visitedChains.add(traversalKey)
+
const baseTable = tableLookup[tableName]
if (!baseTable) {
return null
@@ -613,6 +620,9 @@ const resolveFieldTraverserTarget = (
if (!currentField) {
const nextField: DatabaseSchemaField | undefined = currentTable?.fields?.[segmentKey]
if (!nextField) {
+ if (currentTable && Object.keys(currentTable.fields ?? {}).length === 0) {
+ onUnloadedTable?.(currentTable.name)
+ }
return null
}
currentField = nextField
@@ -641,7 +651,13 @@ const resolveFieldTraverserTarget = (
return null
}
visitedChains.add(chainKey)
- currentField = resolveFieldTraverserTarget(tableName, currentField, tableLookup, visitedChains)
+ currentField = resolveFieldTraverserTarget(
+ tableName,
+ currentField,
+ tableLookup,
+ visitedChains,
+ onUnloadedTable
+ )
if (!currentField) {
return null
}
@@ -652,7 +668,10 @@ const resolveFieldTraverserTarget = (
}
if (currentField?.type === 'field_traverser') {
- return resolveFieldTraverserTarget(tableName, currentField, tableLookup, visitedChains) ?? currentField
+ return (
+ resolveFieldTraverserTarget(tableName, currentField, tableLookup, visitedChains, onUnloadedTable) ??
+ currentField
+ )
}
return currentField
diff --git a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts
index 0050ed9656fb..fd39485b19cc 100644
--- a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts
+++ b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts
@@ -32,6 +32,7 @@ import { initKeaTests } from '~/test/init'
import { ChartDisplayType, InsightShortId, InsightModel } from '~/types'
import { metricsLogic } from 'products/data_catalog/frontend/metricsLogic'
+import { biConnectionsLogic } from 'products/data_warehouse/frontend/bi/biConnectionsLogic'
import { sqlEditorDraftStorage } from 'products/data_warehouse/frontend/sqlEditorDraftStorage'
import { BI_EDITOR_EVENTS } from './bi/biEditorAnalytics'
@@ -2639,6 +2640,124 @@ describe('sqlEditorLogic', () => {
biLogic.unmount()
})
+ it.each([false, true])(
+ 'loads connected fields on expansion and keeps their rooted paths (alias=%s)',
+ async (alias) => {
+ await expectLogic(databaseLogic).toFinishAllListeners()
+ const biLogic = biEditorLogic({ tabId: TAB_ID })
+ biLogic.mount()
+ biLogic.actions.restoreState({ editorView: BIEditorView.BI, config })
+ databaseLogic.actions.loadDatabaseSuccess({
+ tables: {
+ events: {
+ id: 'events',
+ name: 'events',
+ type: 'posthog',
+ fields: {
+ ...(alias
+ ? {
+ pdi: {
+ name: 'pdi',
+ type: 'lazy_table' as const,
+ table: 'person_distinct_ids',
+ hogql_value: 'pdi',
+ schema_valid: true,
+ },
+ }
+ : {}),
+ person: {
+ name: 'person',
+ type: alias ? 'field_traverser' : 'lazy_table',
+ chain: alias ? ['pdi', 'person'] : undefined,
+ table: alias ? undefined : 'persons',
+ hogql_value: 'person',
+ schema_valid: true,
+ },
+ },
+ },
+ persons: { id: 'persons', name: 'persons', type: 'posthog', fields: {} },
+ ...(alias
+ ? {
+ person_distinct_ids: {
+ id: 'person_distinct_ids',
+ name: 'person_distinct_ids',
+ type: 'posthog' as const,
+ fields: {},
+ },
+ }
+ : {}),
+ },
+ joins: [],
+ })
+ databaseLogic.actions.setDatabaseFieldsComplete(false)
+ const connections = biConnectionsLogic({ tabId: TAB_ID })
+ connections.mount()
+ expect(connections.values.connections.find((c) => c.name === 'person')).toMatchObject({
+ expanded: false,
+ state: 'loading',
+ })
+ expect(databaseLogic.values.tableFieldsStatus.persons).toBeUndefined()
+
+ queryEndpointMock.mockReturnValue([
+ 200,
+ {
+ tables: {
+ persons: {
+ id: 'persons',
+ name: 'persons',
+ type: 'posthog',
+ fields: {
+ email: { name: 'email', type: 'string', hogql_value: 'email', schema_valid: true },
+ },
+ },
+ },
+ joins: [],
+ },
+ ])
+ if (alias) {
+ queryEndpointMock.mockReturnValueOnce([
+ 200,
+ {
+ tables: {
+ person_distinct_ids: {
+ id: 'person_distinct_ids',
+ name: 'person_distinct_ids',
+ type: 'posthog',
+ fields: {
+ person: {
+ name: 'person',
+ type: 'lazy_table',
+ table: 'persons',
+ hogql_value: 'person',
+ schema_valid: true,
+ },
+ },
+ },
+ },
+ joins: [],
+ },
+ ])
+ }
+ useMocks({ post: { '/api/environments/:team_id/query/DatabaseSchemaQuery/': queryEndpointMock } })
+ await expectLogic(databaseLogic, () =>
+ connections.actions.toggleConnection('["person"]', alias ? 'person_distinct_ids' : 'persons')
+ ).toDispatchActions(
+ alias ? ['hydrateTableFieldsSuccess', 'hydrateTableFieldsSuccess'] : ['hydrateTableFieldsSuccess']
+ )
+ expect(connections.values.connections.find((c) => c.name === 'person')!.fields.dimensions).toEqual([
+ expect.objectContaining({
+ name: 'person.email',
+ expression: 'person.email',
+ source: config.source,
+ }),
+ ])
+ biLogic.actions.setDataSource({ table: 'persons' })
+ expect(connections.values.connections).toEqual([])
+ connections.unmount()
+ biLogic.unmount()
+ }
+ )
+
it('creates and edits a calculated measure without applying canceled drafts or losing its sort', () => {
const biLogic = biEditorLogic({ tabId: TAB_ID })
biLogic.mount()
diff --git a/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx b/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx
new file mode 100644
index 000000000000..58a8d58315a0
--- /dev/null
+++ b/products/data_warehouse/frontend/bi/BIConnectionGroup.tsx
@@ -0,0 +1,81 @@
+import { useActions } from 'kea'
+
+import { IconChevronRight, IconDatabase } from '@posthog/icons'
+import { LemonButton, Spinner } from '@posthog/lemon-ui'
+
+import { cn } from 'lib/utils/css-classes'
+
+import { biConnectionsLogic } from './biConnectionsLogic'
+import { BIConnection } from './biConnectionTree'
+import { BIDataPaneSection } from './BIDataPaneSection'
+
+export function BIConnectionGroup({ connections }: { connections: BIConnection[] }): JSX.Element | null {
+ const { toggleConnection, hydrateTableFields } = useActions(biConnectionsLogic)
+ if (!connections.length) {
+ return null
+ }
+ return (
+
+ {connections.map((connection) => (
+
+
}
+ onClick={() => toggleConnection(connection.id, connection.tableName)}
+ aria-expanded={connection.expanded}
+ data-attr="bi-editor-connection"
+ tooltip={connection.path.join('.')}
+ >
+
+
{connection.name}
+
+ {connection.expanded ? (
+
+ {connection.state === 'loading' ? (
+
+ Loading fields
+
+ ) : connection.state === 'error' ? (
+
+ Couldn't load fields.
+
+ connection.tableName && hydrateTableFields([connection.tableName])
+ }
+ >
+ Retry
+
+
+ ) : connection.state === 'missing' ? (
+
+ Fields are unavailable. Check that the linked table still exists.
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+ ) : null}
+
+ ))}
+
+ )
+}
diff --git a/products/data_warehouse/frontend/bi/BIConnections.tsx b/products/data_warehouse/frontend/bi/BIConnections.tsx
new file mode 100644
index 000000000000..0bab933ca361
--- /dev/null
+++ b/products/data_warehouse/frontend/bi/BIConnections.tsx
@@ -0,0 +1,20 @@
+import { BindLogic, useMountedLogic, useValues } from 'kea'
+
+import { biEditorLogic } from 'scenes/data-warehouse/editor/bi/biEditorLogic'
+
+import { BIConnectionGroup } from './BIConnectionGroup'
+import { biConnectionsLogic } from './biConnectionsLogic'
+
+export function BIConnections(): JSX.Element {
+ const biLogic = useMountedLogic(biEditorLogic)
+ const logicProps = { tabId: biLogic.props.tabId }
+ const { filteredConnections } = useValues(biConnectionsLogic(logicProps))
+ return (
+
+ {filteredConnections.length ? (
+ Connections
+ ) : null}
+
+
+ )
+}
diff --git a/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx
new file mode 100644
index 000000000000..03d9ece1968f
--- /dev/null
+++ b/products/data_warehouse/frontend/bi/BIDataPaneSection.tsx
@@ -0,0 +1,93 @@
+import { useActions } from 'kea'
+
+import { IconCalendar } from '@posthog/icons'
+
+import { cn } from 'lib/utils/css-classes'
+import { biEditorLogic } from 'scenes/data-warehouse/editor/bi/biEditorLogic'
+import {
+ BIField,
+ BI_FIELD_DRAG_MIME_TYPE,
+ getBIDropTarget,
+ serializeBIField,
+} from 'scenes/data-warehouse/editor/bi/biEditorTypes'
+
+function FieldTypeGlyph({ field, measure }: { field: BIField; measure: boolean }): JSX.Element {
+ const glyph =
+ field.type === 'date' || field.type === 'datetime' ? (
+
+ ) : measure || ['integer', 'float', 'decimal'].includes(field.type) ? (
+ '#'
+ ) : field.type === 'boolean' ? (
+ 'T|F'
+ ) : field.type === 'json' || field.type === 'array' ? (
+ '{ }'
+ ) : (
+ 'Abc'
+ )
+ return (
+
+ {glyph}
+
+ )
+}
+
+export function BIDataPaneSection({
+ title,
+ fields,
+ measure,
+ emptyText,
+ path = [],
+}: {
+ title?: string
+ fields: BIField[]
+ measure: boolean
+ emptyText: string
+ path?: string[]
+}): JSX.Element {
+ const { addFieldToShelf } = useActions(biEditorLogic)
+
+ const addField = (field: BIField): void => {
+ const target = getBIDropTarget(field, 'rows')
+ addFieldToShelf(target.field, target.shelf)
+ }
+
+ return (
+
+ {title ?
{title}
: null}
+ {fields.length === 0 ?
{emptyText} : null}
+ {fields.map((field) => (
+
{
+ event.dataTransfer.effectAllowed = 'copy'
+ event.dataTransfer.setData(BI_FIELD_DRAG_MIME_TYPE, serializeBIField(field))
+ }}
+ onDoubleClick={() => addField(field)}
+ onClick={(event) => {
+ if (event.detail === 0) {
+ addField(field)
+ }
+ }}
+ data-attr={measure ? 'bi-editor-data-pane-measure' : 'bi-editor-data-pane-dimension'}
+ >
+
+
+ {path.length ? field.name.slice(path.join('.').length + 1) : field.name}
+
+
+ ))}
+
+ )
+}
diff --git a/products/data_warehouse/frontend/bi/biConnectionTree.test.ts b/products/data_warehouse/frontend/bi/biConnectionTree.test.ts
new file mode 100644
index 000000000000..2a35595ece30
--- /dev/null
+++ b/products/data_warehouse/frontend/bi/biConnectionTree.test.ts
@@ -0,0 +1,176 @@
+import {
+ BIDataSource,
+ BIField,
+ buildBIQuery,
+ DEFAULT_BI_CONFIG,
+ getBIDropTarget,
+} from 'scenes/data-warehouse/editor/bi/biEditorTypes'
+
+import { DatabaseSchemaField, DatabaseSchemaTable } from '~/queries/schema/schema-general'
+
+import { buildBIConnections, filterBIConnections, getPendingBIConnectionTables } from './biConnectionTree'
+
+const field = (
+ name: string,
+ type: DatabaseSchemaField['type'],
+ rest: Partial = {}
+): DatabaseSchemaField => ({ name, type, hogql_value: name, schema_valid: true, ...rest })
+const table = (name: string, fields: DatabaseSchemaField[]): DatabaseSchemaTable => ({
+ id: name,
+ name,
+ type: 'posthog',
+ fields: Object.fromEntries(fields.map((field) => [field.name, field])),
+})
+const source: BIDataSource = { table: 'events', connectionId: 'example-connection' }
+const tables = {
+ events: table('events', [field('person', 'lazy_table', { table: 'persons' })]),
+ persons: table('persons', [
+ field('email', 'string'),
+ field('lifetime_value', 'float'),
+ field('company', 'lazy_table', { table: 'companies' }),
+ ]),
+ companies: table('companies', [field('name', 'string'), field('annual_revenue', 'decimal')]),
+}
+
+describe('BI connections', () => {
+ it('drags nested dimensions and measures using paths from the original source', () => {
+ const [person] = buildBIConnections(source, tables, ['["person"]', '["person","company"]'], {}, true)
+ const [company] = person.connections
+ expect(company.connections).toEqual([])
+ const dimension = company.fields.dimensions[0]
+ const measure = company.fields.measures[0]
+ expect(dimension).toMatchObject({ name: 'person.company.name', expression: 'person.company.name', source })
+ expect(measure).toMatchObject({ name: 'person.company.annual_revenue', type: 'decimal', source })
+ const value = getBIDropTarget(measure, 'rows').field as BIField
+ const query = buildBIQuery({
+ ...DEFAULT_BI_CONFIG,
+ source,
+ rows: [dimension],
+ values: [{ field: value, aggregation: 'sum' }],
+ })
+ expect(query?.query).toContain('person.company.name')
+ expect(query?.query).toContain('sum(person.company.annual_revenue)')
+ expect(query?.query).toContain('FROM events')
+ })
+
+ it.each(['lazy_table', 'view', 'materialized_view', 'virtual_table'] as const)(
+ 'uses declared fields for %s connections',
+ (type) => {
+ const catalog = {
+ ...tables,
+ events: table('events', [field('person', type, { table: 'persons', fields: ['email'] })]),
+ }
+ const [person] = buildBIConnections(source, catalog, ['["person"]'], {}, true)
+ expect(person.fields.dimensions.map((field) => field.name)).toEqual(['person.email'])
+ expect(person.fields.measures).toEqual([])
+ expect(person.connections).toEqual([])
+ }
+ )
+
+ it('uses the PostHog table ID for hydration and status', () => {
+ const catalog = { ...tables, persons: { ...table('persons', []), id: 'person-table-id' } }
+ const expanded = ['["person"]']
+ expect(getPendingBIConnectionTables(buildBIConnections(source, catalog, expanded, {}, false))).toEqual([
+ 'person-table-id',
+ ])
+ expect(buildBIConnections(source, catalog, expanded, { 'person-table-id': 'error' }, false)[0].state).toBe(
+ 'error'
+ )
+ })
+
+ it('resolves linked aliases and only expands requested paths through cyclic tables', () => {
+ const catalog = {
+ ...tables,
+ events: table('events', [
+ field('person', 'lazy_table', { table: 'persons' }),
+ field('customer', 'field_traverser', { chain: ['person'] }),
+ ]),
+ persons: table('persons', [field('email', 'string'), field('manager', 'lazy_table', { table: 'persons' })]),
+ }
+ const [customer] = buildBIConnections(source, catalog, ['["customer"]', '["customer","manager"]'], {}, true)
+ expect(customer.fields.dimensions[0].expression).toBe('customer.email')
+ const [manager] = customer.connections
+ expect(manager.fields.dimensions[0].expression).toBe('customer.manager.email')
+ expect(manager.connections[0]).toMatchObject({ expanded: false, connections: [] })
+ })
+
+ it.each([
+ [undefined, false, 'loading'],
+ ['error', false, 'error'],
+ ['missing', false, 'missing'],
+ ['loaded', false, 'ready'],
+ [undefined, true, 'ready'],
+ ] as const)(
+ 'distinguishes unresolved, failed, and empty connections (%s, complete=%s)',
+ (status, complete, state) => {
+ const catalog = { ...tables, persons: table('persons', []) }
+ const [person] = buildBIConnections(
+ source,
+ catalog,
+ ['["person"]'],
+ status ? { persons: status } : {},
+ complete
+ )
+ expect(person.state).toBe(state)
+ expect(person.fields).toEqual({ dimensions: [], measures: [] })
+ }
+ )
+
+ it('does not recurse forever when aliases refer to each other', () => {
+ const catalog = {
+ events: table('events', [
+ field('first', 'field_traverser', { chain: ['second'] }),
+ field('second', 'field_traverser', { chain: ['first'] }),
+ ]),
+ }
+ expect(buildBIConnections(source, catalog, [], {}, true)).toEqual([])
+ })
+
+ it('lists virtual fields without borrowing types or connections from the printed parent table', () => {
+ const catalog = {
+ events: table('events', [
+ field('id', 'integer'),
+ field('created_at', 'lazy_table', { table: 'companies' }),
+ field('person', 'field_traverser', { chain: ['poe'] }),
+ field('poe', 'virtual_table', {
+ table: 'events',
+ fields: ['id', 'created_at', 'properties', 'team_id'],
+ }),
+ ]),
+ }
+ const person = buildBIConnections(source, catalog, ['["person"]'], { events: 'error' }, false).find(
+ (c) => c.name === 'person'
+ )!
+ expect(person).toMatchObject({ state: 'ready', tableName: undefined, connections: [] })
+ expect(person.fields.dimensions).toEqual([
+ expect.objectContaining({ name: 'person.created_at', type: 'unknown', source }),
+ expect.objectContaining({ name: 'person.id', type: 'unknown', source }),
+ expect.objectContaining({ name: 'person.properties', type: 'unknown', source }),
+ ])
+ expect(person.fields.measures).toEqual([])
+ expect(getPendingBIConnectionTables([person])).toEqual([])
+ })
+
+ it.each([undefined, 'error'] as const)(
+ 'keeps unresolved aliases visible and searchable while intermediate fields are %s',
+ (status) => {
+ const catalog = {
+ events: table('events', [
+ field('person', 'field_traverser', { chain: ['pdi', 'person'] }),
+ field('pdi', 'lazy_table', { table: 'person_distinct_ids' }),
+ ]),
+ person_distinct_ids: table('person_distinct_ids', []),
+ }
+ const connections = buildBIConnections(
+ source,
+ catalog,
+ ['["person"]'],
+ status ? { person_distinct_ids: status } : {},
+ false
+ )
+ const person = filterBIConnections(connections, 'email').find((c) => c.name === 'person')!
+ expect(person).toMatchObject({ state: status ?? 'loading', tableName: 'person_distinct_ids' })
+ expect(getPendingBIConnectionTables(connections)).toEqual(status ? [] : ['person_distinct_ids'])
+ }
+ )
+})
diff --git a/products/data_warehouse/frontend/bi/biConnectionTree.ts b/products/data_warehouse/frontend/bi/biConnectionTree.ts
new file mode 100644
index 000000000000..8cbf7b50a156
--- /dev/null
+++ b/products/data_warehouse/frontend/bi/biConnectionTree.ts
@@ -0,0 +1,188 @@
+import { TableFieldsStatus } from 'scenes/data-management/database/databaseTableListLogic'
+import { BIDataPaneFields, BIDataSource, getBIDataPaneFields } from 'scenes/data-warehouse/editor/bi/biEditorTypes'
+import { resolveFieldTraverserTarget } from 'scenes/data-warehouse/editor/sidebar/queryDatabaseLogic'
+
+import { DatabaseSchemaField, DatabaseSchemaTable } from '~/queries/schema/schema-general'
+
+export interface BIConnection {
+ id: string
+ name: string
+ path: string[]
+ tableName?: string
+ expanded: boolean
+ state: 'ready' | 'loading' | 'error' | 'missing'
+ fields: BIDataPaneFields
+ connections: BIConnection[]
+}
+
+function getHydrationKey(table: DatabaseSchemaTable): string {
+ return table.type === 'posthog' ? table.id : table.name
+}
+
+function getConnectionState(
+ field: DatabaseSchemaField,
+ table: DatabaseSchemaTable | null,
+ status: TableFieldsStatus[string] | undefined,
+ complete: boolean
+): BIConnection['state'] {
+ if (field.type === 'virtual_table') {
+ return 'ready'
+ }
+ if (status === 'error' || status === 'missing') {
+ return status
+ }
+ if (table && !complete && !Object.keys(table.fields).length && status !== 'loaded') {
+ return 'loading'
+ }
+ return !table && !field.fields?.length ? 'missing' : 'ready'
+}
+
+function getConnectionFields(field: DatabaseSchemaField, table: DatabaseSchemaTable | null): DatabaseSchemaField[] {
+ // A virtual table's printed table is not the owner of its child field definitions.
+ const fieldsTable = field.type === 'virtual_table' ? null : table
+ return field.fields?.length
+ ? field.fields
+ .filter((name) => name !== 'team_id')
+ .map(
+ (name): DatabaseSchemaField =>
+ fieldsTable?.fields[name] ?? {
+ name,
+ hogql_value: name,
+ type: 'unknown',
+ schema_valid: true,
+ }
+ )
+ : Object.values(fieldsTable?.fields ?? {}).filter(
+ (child) => child.name !== 'team_id' || child.type !== 'unknown'
+ )
+}
+
+export function buildBIConnections(
+ source: BIDataSource,
+ tables: Record,
+ expandedIds: string[],
+ tableFieldsStatus: TableFieldsStatus,
+ databaseFieldsComplete: boolean
+): BIConnection[] {
+ const expanded = new Set(expandedIds)
+ const getTable = (name?: string): DatabaseSchemaTable | null =>
+ name ? (tables[name] ?? tables[name.replaceAll('`', '')] ?? null) : null
+ const visit = (tableName: string, fields: DatabaseSchemaField[], path: string[]): BIConnection[] => {
+ const tableLookup = {
+ ...tables,
+ [tableName]: { name: tableName, fields: Object.fromEntries(fields.map((field) => [field.name, field])) },
+ }
+ return fields
+ .flatMap((originalField): BIConnection[] => {
+ let pendingTableName: string | undefined
+ const field =
+ originalField.type === 'field_traverser'
+ ? resolveFieldTraverserTarget(tableName, originalField, tableLookup, new Set(), (name) => {
+ pendingTableName = name
+ })
+ : originalField
+ const pendingTable = pendingTableName ? getTable(pendingTableName) : null
+ const pendingState = pendingTable
+ ? getConnectionState(
+ originalField,
+ pendingTable,
+ tableFieldsStatus[getHydrationKey(pendingTable)],
+ databaseFieldsComplete
+ )
+ : null
+ if (
+ (!field || !['lazy_table', 'virtual_table', 'view', 'materialized_view'].includes(field.type)) &&
+ (!pendingState || pendingState === 'ready')
+ ) {
+ return []
+ }
+ const childPath = [...path, originalField.name]
+ const id = JSON.stringify(childPath)
+ const table = pendingTable ?? (field?.type === 'virtual_table' ? null : getTable(field?.table))
+ const status = table ? tableFieldsStatus[getHydrationKey(table)] : undefined
+ const state = pendingState ?? getConnectionState(field!, table, status, databaseFieldsComplete)
+ const connection: BIConnection = {
+ id,
+ name: originalField.name,
+ path: childPath,
+ tableName: table ? getHydrationKey(table) : undefined,
+ expanded: expanded.has(id),
+ state,
+ fields: { dimensions: [], measures: [] },
+ connections: [],
+ }
+ if (!connection.expanded || state !== 'ready' || !field) {
+ return [connection]
+ }
+ const childFields = getConnectionFields(field, table)
+ const nestedTableName = table?.name ?? field.table ?? tableName
+ const childLookup = {
+ ...tables,
+ [nestedTableName]: {
+ name: nestedTableName,
+ fields: Object.fromEntries(childFields.map((child) => [child.name, child])),
+ },
+ }
+ const scalarFields = childFields.map((child) => {
+ const resolved =
+ child.type === 'field_traverser'
+ ? resolveFieldTraverserTarget(nestedTableName, child, childLookup)
+ : child
+ return resolved ? { ...resolved, name: child.name } : child
+ })
+ return [
+ {
+ ...connection,
+ fields: getBIDataPaneFields(
+ { fields: Object.fromEntries(scalarFields.map((child) => [child.name, child])) },
+ source,
+ childPath
+ ),
+ connections: visit(nestedTableName, childFields, childPath),
+ },
+ ]
+ })
+ .sort((left, right) => left.name.localeCompare(right.name))
+ }
+ return visit(source.table, Object.values(getTable(source.table)?.fields ?? {}), [])
+}
+
+export function getPendingBIConnectionTables(connections: BIConnection[]): string[] {
+ return [
+ ...new Set(
+ connections.flatMap((connection) =>
+ connection.expanded
+ ? [
+ ...(connection.state === 'loading' && connection.tableName ? [connection.tableName] : []),
+ ...getPendingBIConnectionTables(connection.connections),
+ ]
+ : []
+ )
+ ),
+ ]
+}
+
+export function filterBIConnections(connections: BIConnection[], search: string): BIConnection[] {
+ const term = search.trim().toLowerCase()
+ if (!term) {
+ return connections
+ }
+ return connections.flatMap((connection) => {
+ if (connection.path.join('.').toLowerCase().includes(term)) {
+ return [connection]
+ }
+ const fields = {
+ dimensions: connection.fields.dimensions.filter((field) => field.name.toLowerCase().includes(term)),
+ measures: connection.fields.measures.filter((field) => field.name.toLowerCase().includes(term)),
+ }
+ const children = filterBIConnections(connection.connections, term)
+ return !connection.expanded ||
+ fields.dimensions.length ||
+ fields.measures.length ||
+ children.length ||
+ connection.state === 'loading' ||
+ connection.state === 'error'
+ ? [{ ...connection, fields, connections: children }]
+ : []
+ })
+}
diff --git a/products/data_warehouse/frontend/bi/biConnectionsLogic.ts b/products/data_warehouse/frontend/bi/biConnectionsLogic.ts
new file mode 100644
index 000000000000..32540e430d70
--- /dev/null
+++ b/products/data_warehouse/frontend/bi/biConnectionsLogic.ts
@@ -0,0 +1,163 @@
+import {
+ MakeLogicType,
+ LogicWrapper,
+ actions,
+ connect,
+ kea,
+ key,
+ listeners,
+ path,
+ props,
+ reducers,
+ selectors,
+} from 'kea'
+
+import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic'
+import { biEditorLogic } from 'scenes/data-warehouse/editor/bi/biEditorLogic'
+
+import type { DatabaseSchemaTable } from '../../../../frontend/src/queries/schema/schema-general'
+import type { TableFieldsStatus } from '../../../../frontend/src/scenes/data-management/database/databaseTableListLogic'
+import type { BIConfig } from '../../../../frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes'
+import { buildBIConnections, filterBIConnections, getPendingBIConnectionTables } from './biConnectionTree'
+import type { BIConnection } from './biConnectionTree'
+
+export interface BIConnectionsLogicProps {
+ tabId: string
+}
+
+// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
+export interface biConnectionsLogicValues {
+ config: BIConfig // biEditorLogic
+ dataPaneSearch: string // biEditorLogic
+ databaseConnectionId: string | null // biEditorLogic
+ allTablesMap: Record // databaseTableListLogic
+ databaseFieldsComplete: boolean // databaseTableListLogic
+ tableFieldsStatus: TableFieldsStatus // databaseTableListLogic
+ connections: BIConnection[]
+ expandedIds: string[]
+ filteredConnections: BIConnection[]
+}
+
+// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
+export interface biConnectionsLogicActions {
+ resetConfig: () => {
+ value: true
+ } // biEditorLogic
+ setDataSource: (
+ source: import('../../../../frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes').BIDataSource
+ ) => {
+ source: import('../../../../frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes').BIDataSource
+ } // biEditorLogic
+ hydrateTableFields: (tableNames: string[]) => {
+ tableNames: string[]
+ } // databaseTableListLogic
+ hydrateTableFieldsSuccess: (
+ tableNames: string[],
+ tables: Record
+ ) => {
+ tableNames: string[]
+ tables: Record
+ } // databaseTableListLogic
+ toggleConnection: (
+ id: string,
+ tableName?: string
+ ) => {
+ id: string
+ tableName: string | undefined
+ }
+}
+
+// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
+export interface biConnectionsLogicMeta {
+ key: string
+ __keaTypeGenInternalSelectorTypes: {
+ connections: (
+ config: BIConfig,
+ allTablesMap: Record,
+ expandedIds: string[],
+ tableFieldsStatus: TableFieldsStatus,
+ databaseFieldsComplete: boolean,
+ databaseConnectionId: string | null
+ ) => BIConnection[]
+ filteredConnections: (connections: BIConnection[], dataPaneSearch: string) => BIConnection[]
+ }
+}
+
+export type biConnectionsLogicType = MakeLogicType<
+ biConnectionsLogicValues,
+ biConnectionsLogicActions,
+ BIConnectionsLogicProps,
+ biConnectionsLogicMeta
+>
+
+export const biConnectionsLogic: LogicWrapper = kea([
+ props({} as BIConnectionsLogicProps),
+ key((props) => props.tabId),
+ path((key) => ['products', 'data_warehouse', 'frontend', 'bi', 'biConnectionsLogic', key]),
+ connect((props: BIConnectionsLogicProps) => ({
+ values: [
+ biEditorLogic({ tabId: props.tabId }),
+ ['config', 'dataPaneSearch', 'databaseConnectionId'],
+ databaseTableListLogic,
+ ['allTablesMap', 'tableFieldsStatus', 'databaseFieldsComplete'],
+ ],
+ actions: [
+ biEditorLogic({ tabId: props.tabId }),
+ ['setDataSource', 'resetConfig'],
+ databaseTableListLogic,
+ ['hydrateTableFields', 'hydrateTableFieldsSuccess'],
+ ],
+ })),
+ actions({ toggleConnection: (id: string, tableName?: string) => ({ id, tableName }) }),
+ reducers({
+ expandedIds: [
+ [] as string[],
+ {
+ toggleConnection: (state, { id }) =>
+ state.includes(id) ? state.filter((key) => key !== id) : [...state, id],
+ setDataSource: () => [],
+ resetConfig: () => [],
+ },
+ ],
+ }),
+ selectors({
+ connections: [
+ (s) => [
+ s.config,
+ s.allTablesMap,
+ s.expandedIds,
+ s.tableFieldsStatus,
+ s.databaseFieldsComplete,
+ s.databaseConnectionId,
+ ],
+ (
+ config: BIConfig,
+ tables: Record,
+ expanded: string[],
+ status: TableFieldsStatus,
+ complete: boolean,
+ connectionId: string | null
+ ): BIConnection[] =>
+ config.source && (config.source.connectionId ?? null) === connectionId
+ ? buildBIConnections(config.source, tables, expanded, status, complete)
+ : [],
+ ],
+ filteredConnections: [
+ (s) => [s.connections, s.dataPaneSearch],
+ (connections: BIConnection[], search: string): BIConnection[] => filterBIConnections(connections, search),
+ ],
+ }),
+ listeners(({ actions, values }) => ({
+ hydrateTableFieldsSuccess: () => {
+ const tables = getPendingBIConnectionTables(values.connections)
+ if (tables.length) {
+ actions.hydrateTableFields(tables)
+ }
+ },
+ toggleConnection: ({ id, tableName }) => {
+ if (tableName && values.expandedIds.includes(id)) {
+ actions.hydrateTableFields([tableName])
+ }
+ },
+ })),
+])