This is an automated email from the ASF dual-hosted git repository.

kgabryje pushed a commit to branch what-if
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 4eaf707aabf6450be238dd5b55d2dcd306ebf68a
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Thu Dec 18 14:25:53 2025 +0100

    Handle sql expressions
---
 .../dashboard/components/WhatIfDrawer/index.tsx    | 140 ++++++++------
 .../util/charts/getFormDataWithExtraFilters.ts     |  21 +-
 .../src/dashboard/util/useWhatIfHighlightStyles.ts |  25 ++-
 .../src/dashboard/util/whatIf.test.ts              | 211 +++++++++++++++++++++
 superset-frontend/src/dashboard/util/whatIf.ts     | 156 ++++++++++++++-
 5 files changed, 488 insertions(+), 65 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx 
b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
index 3666af1b47..a381f60f01 100644
--- a/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
+++ b/superset-frontend/src/dashboard/components/WhatIfDrawer/index.tsx
@@ -25,6 +25,7 @@ import {
   Select,
   Checkbox,
   Tooltip,
+  Tag,
 } from '@superset-ui/core/components';
 import Slider from '@superset-ui/core/components/Slider';
 import { Icons } from '@superset-ui/core/components/Icons';
@@ -39,7 +40,7 @@ import WhatIfAIInsights from './WhatIfAIInsights';
 import { fetchRelatedColumnSuggestions } from './whatIfApi';
 import { ExtendedWhatIfModification } from './types';
 
-export const WHAT_IF_PANEL_WIDTH = 300;
+export const WHAT_IF_PANEL_WIDTH = 340;
 
 const SLIDER_MIN = -50;
 const SLIDER_MAX = 50;
@@ -102,7 +103,7 @@ const PanelContent = styled.div`
   padding: ${({ theme }) => theme.sizeUnit * 4}px;
   display: flex;
   flex-direction: column;
-  gap: ${({ theme }) => theme.sizeUnit * 4}px;
+  gap: ${({ theme }) => theme.sizeUnit * 5}px;
 `;
 
 const FormSection = styled.div`
@@ -136,56 +137,56 @@ const CheckboxContainer = styled.div`
 const ModificationsSection = styled.div`
   display: flex;
   flex-direction: column;
-  gap: ${({ theme }) => theme.sizeUnit * 2}px;
+  gap: ${({ theme }) => theme.sizeUnit * 5}px;
 `;
 
-const ModificationsSectionTitle = styled.div`
-  color: ${({ theme }) => theme.colorText};
-  font-size: ${({ theme }) => theme.fontSizeSM}px;
+const ModificationTagsContainer = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  gap: ${({ theme }) => theme.sizeUnit * 2}px;
 `;
 
-const ModificationCard = styled.div<{ isAISuggested?: boolean }>`
-  padding: ${({ theme }) => theme.sizeUnit * 2}px;
-  background-color: ${({ theme, isAISuggested }) =>
-    isAISuggested ? theme.colorInfoBg : theme.colorBgLayout};
-  border: 1px solid
-    ${({ theme, isAISuggested }) =>
-      isAISuggested ? theme.colorInfoBorder : theme.colorBorderSecondary};
-  border-radius: ${({ theme }) => theme.borderRadius}px;
+const AIBadge = styled.span`
+  font-size: 10px;
+  padding: 0 4px;
+  background-color: ${({ theme }) => theme.colorInfo};
+  color: ${({ theme }) => theme.colorWhite};
+  border-radius: 16px;
+  line-height: 1.2;
 `;
 
-const ModificationHeader = styled.div`
+const AIReasoningSection = styled.div`
   display: flex;
-  align-items: center;
-  justify-content: space-between;
+  flex-direction: column;
   gap: ${({ theme }) => theme.sizeUnit}px;
 `;
 
-const ModificationColumn = styled.span`
-  font-weight: ${({ theme }) => theme.fontWeightStrong};
-  color: ${({ theme }) => theme.colorText};
-`;
+const AIReasoningToggle = styled.button`
+  display: flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+  color: ${({ theme }) => theme.colorTextTertiary};
+  font-size: ${({ theme }) => theme.fontSizeSM}px;
 
-const ModificationValue = styled.span<{ isPositive: boolean }>`
-  font-weight: ${({ theme }) => theme.fontWeightStrong};
-  color: ${({ theme, isPositive }) =>
-    isPositive ? theme.colorSuccess : theme.colorError};
+  &:hover {
+    color: ${({ theme }) => theme.colorText};
+  }
 `;
 
-const AIBadge = styled.span`
-  font-size: ${({ theme }) => theme.fontSizeXS}px;
-  padding: 2px 6px;
-  background-color: ${({ theme }) => theme.colorInfo};
-  color: ${({ theme }) => theme.colorWhite};
-  border-radius: ${({ theme }) => theme.borderRadius}px;
-  font-weight: ${({ theme }) => theme.fontWeightStrong};
+const AIReasoningContent = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+  padding-left: ${({ theme }) => theme.sizeUnit * 4}px;
 `;
 
-const ModificationReasoning = styled.div`
+const AIReasoningItem = styled.div`
   font-size: ${({ theme }) => theme.fontSizeSM}px;
   color: ${({ theme }) => theme.colorTextSecondary};
-  margin-top: ${({ theme }) => theme.sizeUnit}px;
-  font-style: italic;
 `;
 
 interface WhatIfPanelProps {
@@ -207,6 +208,7 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
   >([]);
   // Counter that increments each time Apply is clicked, used as key to reset 
AI insights
   const [applyCounter, setApplyCounter] = useState(0);
+  const [showAIReasoning, setShowAIReasoning] = useState(false);
 
   const slices = useSelector(
     (state: RootState) => state.sliceEntities.slices as { [id: number]: Slice 
},
@@ -458,31 +460,57 @@ const WhatIfPanel = ({ onClose, topOffset }: 
WhatIfPanelProps) => {
 
         {appliedModifications.length > 0 && (
           <ModificationsSection>
-            <ModificationsSectionTitle>
-              {t('Applied modifications')}
-            </ModificationsSectionTitle>
-            {appliedModifications.map((mod, idx) => (
-              <ModificationCard key={idx} isAISuggested={mod.isAISuggested}>
-                <ModificationHeader>
-                  <ModificationColumn>{mod.column}</ModificationColumn>
-                  <div
+            <ModificationTagsContainer>
+              {appliedModifications.map((mod, idx) => (
+                <Tag
+                  key={idx}
+                  css={css`
+                    display: inline-flex;
+                    align-items: center;
+                    gap: ${theme.sizeUnit}px;
+                    margin: 0;
+                  `}
+                >
+                  <span>{mod.column}</span>
+                  {mod.isAISuggested && <AIBadge>{t('AI')}</AIBadge>}
+                  <span
                     css={css`
-                      display: flex;
-                      align-items: center;
-                      gap: ${theme.sizeUnit}px;
+                      font-weight: ${theme.fontWeightStrong};
+                      color: ${mod.multiplier >= 1
+                        ? theme.colorSuccess
+                        : theme.colorError};
                     `}
                   >
-                    <ModificationValue isPositive={mod.multiplier >= 1}>
-                      {formatPercentage(mod.multiplier)}
-                    </ModificationValue>
-                    {mod.isAISuggested && <AIBadge>{t('AI')}</AIBadge>}
-                  </div>
-                </ModificationHeader>
-                {mod.reasoning && (
-                  
<ModificationReasoning>{mod.reasoning}</ModificationReasoning>
+                    {formatPercentage(mod.multiplier)}
+                  </span>
+                </Tag>
+              ))}
+            </ModificationTagsContainer>
+            {appliedModifications.some(mod => mod.reasoning) && (
+              <AIReasoningSection>
+                <AIReasoningToggle
+                  onClick={() => setShowAIReasoning(!showAIReasoning)}
+                >
+                  {showAIReasoning ? (
+                    <Icons.DownOutlined iconSize="xs" />
+                  ) : (
+                    <Icons.RightOutlined iconSize="xs" />
+                  )}
+                  {t('How AI chose these')}
+                </AIReasoningToggle>
+                {showAIReasoning && (
+                  <AIReasoningContent>
+                    {appliedModifications
+                      .filter(mod => mod.reasoning)
+                      .map((mod, idx) => (
+                        <AIReasoningItem key={idx}>
+                          <strong>{mod.column}:</strong> {mod.reasoning}
+                        </AIReasoningItem>
+                      ))}
+                  </AIReasoningContent>
                 )}
-              </ModificationCard>
-            ))}
+              </AIReasoningSection>
+            )}
           </ModificationsSection>
         )}
 
diff --git 
a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts 
b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts
index 9300eb2f9d..c8490632b9 100644
--- a/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts
+++ b/superset-frontend/src/dashboard/util/charts/getFormDataWithExtraFilters.ts
@@ -31,6 +31,7 @@ import {
   ChartQueryPayload,
   ActiveFilters,
   WhatIfModification,
+  Slice,
 } from 'src/dashboard/types';
 import { ChartCustomizationItem } from 
'src/dashboard/components/nativeFilters/ChartCustomization/types';
 import { getExtraFormData } from 
'src/dashboard/components/nativeFilters/utils';
@@ -43,6 +44,10 @@ import {
 } from './chartTypeLimitations';
 import getEffectiveExtraFilters from './getEffectiveExtraFilters';
 import { getAllActiveFilters } from '../activeAllDashboardFilters';
+import {
+  collectSqlExpressionsFromSlice,
+  findColumnsInSqlExpressions,
+} from '../whatIf';
 
 interface CachedFormData {
   extra_form_data?: JsonObject;
@@ -547,8 +552,20 @@ export default function getFormDataWithExtraFilters({
   let whatIfExtras: { what_if?: { modifications: WhatIfModification[] } } = {};
   if (whatIfModifications && whatIfModifications.length > 0) {
     const chartColumns = buildExistingColumnsSet(chart as ChartQueryPayload);
-    const applicableModifications = whatIfModifications.filter(mod =>
-      chartColumns.has(mod.column),
+
+    // Also check if modified columns appear in SQL expressions
+    // (e.g., custom metrics like AVG(orders / customers))
+    const modifiedColumnNames = whatIfModifications.map(mod => mod.column);
+    const sliceForSqlCheck = { form_data: chart.form_data } as Slice;
+    const sqlExpressions = collectSqlExpressionsFromSlice(sliceForSqlCheck);
+    const sqlReferencedColumns = findColumnsInSqlExpressions(
+      sqlExpressions,
+      modifiedColumnNames,
+    );
+
+    const applicableModifications = whatIfModifications.filter(
+      mod =>
+        chartColumns.has(mod.column) || sqlReferencedColumns.has(mod.column),
     );
 
     if (applicableModifications.length > 0) {
diff --git a/superset-frontend/src/dashboard/util/useWhatIfHighlightStyles.ts 
b/superset-frontend/src/dashboard/util/useWhatIfHighlightStyles.ts
index 07182642fc..24150d914b 100644
--- a/superset-frontend/src/dashboard/util/useWhatIfHighlightStyles.ts
+++ b/superset-frontend/src/dashboard/util/useWhatIfHighlightStyles.ts
@@ -20,7 +20,11 @@ import { useMemo } from 'react';
 import { useSelector } from 'react-redux';
 import { css, keyframes } from '@emotion/react';
 import { RootState, WhatIfModification } from 'src/dashboard/types';
-import { extractColumnsFromSlice } from './whatIf';
+import {
+  extractColumnsFromSlice,
+  collectSqlExpressionsFromSlice,
+  findColumnsInSqlExpressions,
+} from './whatIf';
 
 const EMPTY_STYLES = undefined;
 
@@ -119,17 +123,30 @@ const useWhatIfHighlightStyles = (chartId: number) => {
     }
 
     const chartColumns = extractColumnsFromSlice(slice);
-    const modifiedColumns = new Set(
-      whatIfModifications.map((mod: WhatIfModification) => mod.column),
+    const modifiedColumnNames = whatIfModifications.map(
+      (mod: WhatIfModification) => mod.column,
     );
+    const modifiedColumns = new Set(modifiedColumnNames);
 
-    // Check if any of the chart's columns are being modified
+    // Check if any of the chart's explicitly referenced columns are being 
modified
     for (const column of chartColumns) {
       if (modifiedColumns.has(column)) {
         return true;
       }
     }
 
+    // Also check if modified columns appear in SQL expressions
+    const sqlExpressions = collectSqlExpressionsFromSlice(slice);
+    if (sqlExpressions.length > 0) {
+      const sqlReferencedColumns = findColumnsInSqlExpressions(
+        sqlExpressions,
+        modifiedColumnNames,
+      );
+      if (sqlReferencedColumns.size > 0) {
+        return true;
+      }
+    }
+
     return false;
   }, [whatIfModifications, slice]);
 
diff --git a/superset-frontend/src/dashboard/util/whatIf.test.ts 
b/superset-frontend/src/dashboard/util/whatIf.test.ts
new file mode 100644
index 0000000000..4a20e4cdb5
--- /dev/null
+++ b/superset-frontend/src/dashboard/util/whatIf.test.ts
@@ -0,0 +1,211 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+  collectSqlExpressionsFromSlice,
+  findColumnsInSqlExpressions,
+  sliceUsesColumn,
+} from './whatIf';
+import { Slice } from '../types';
+
+const createMockSlice = (formData: Record<string, unknown>): Slice =>
+  ({
+    slice_id: 1,
+    slice_name: 'Test Slice',
+    form_data: formData,
+  }) as Slice;
+
+test('collectSqlExpressionsFromSlice extracts SQL from metrics', () => {
+  const slice = createMockSlice({
+    metrics: [
+      {
+        expressionType: 'SQL',
+        sqlExpression: 'AVG(orders / customers)',
+        label: 'Avg Orders',
+      },
+    ],
+  });
+
+  const expressions = collectSqlExpressionsFromSlice(slice);
+  expect(expressions).toEqual(['AVG(orders / customers)']);
+});
+
+test('collectSqlExpressionsFromSlice extracts SQL from filters', () => {
+  const slice = createMockSlice({
+    adhoc_filters: [
+      {
+        expressionType: 'SQL',
+        sqlExpression: 'revenue > 1000',
+        clause: 'WHERE',
+      },
+    ],
+  });
+
+  const expressions = collectSqlExpressionsFromSlice(slice);
+  expect(expressions).toEqual(['revenue > 1000']);
+});
+
+test('collectSqlExpressionsFromSlice extracts SQL from adhoc columns in 
groupby', () => {
+  const slice = createMockSlice({
+    groupby: [
+      {
+        sqlExpression: "DATE_TRUNC('month', order_date)",
+        label: 'Month',
+      },
+    ],
+  });
+
+  const expressions = collectSqlExpressionsFromSlice(slice);
+  expect(expressions).toEqual(["DATE_TRUNC('month', order_date)"]);
+});
+
+test('collectSqlExpressionsFromSlice extracts SQL from singular metric', () => 
{
+  const slice = createMockSlice({
+    metric: {
+      expressionType: 'SQL',
+      sqlExpression: 'SUM(amount)',
+      label: 'Total Amount',
+    },
+  });
+
+  const expressions = collectSqlExpressionsFromSlice(slice);
+  expect(expressions).toEqual(['SUM(amount)']);
+});
+
+test('collectSqlExpressionsFromSlice ignores SIMPLE expression types', () => {
+  const slice = createMockSlice({
+    metrics: [
+      {
+        expressionType: 'SIMPLE',
+        column: { column_name: 'revenue' },
+        aggregate: 'SUM',
+      },
+    ],
+    adhoc_filters: [
+      {
+        expressionType: 'SIMPLE',
+        subject: 'status',
+        operator: '==',
+        comparator: 'active',
+      },
+    ],
+  });
+
+  const expressions = collectSqlExpressionsFromSlice(slice);
+  expect(expressions).toEqual([]);
+});
+
+test('findColumnsInSqlExpressions finds exact column matches', () => {
+  const sqlExpressions = ['AVG(orders / customers)', 'SUM(revenue)'];
+  const columnNames = ['orders', 'customers', 'revenue', 'total'];
+
+  const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
+  expect(found).toEqual(new Set(['orders', 'customers', 'revenue']));
+});
+
+test('findColumnsInSqlExpressions avoids false positives with similar names', 
() => {
+  const sqlExpressions = ['SUM(order_count)', 'AVG(reorder_rate)'];
+  const columnNames = ['order', 'orders', 'order_count', 'reorder_rate'];
+
+  const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
+  // Should only match exact column names, not partial matches
+  expect(found).toEqual(new Set(['order_count', 'reorder_rate']));
+  expect(found.has('order')).toBe(false);
+  expect(found.has('orders')).toBe(false);
+});
+
+test('findColumnsInSqlExpressions handles columns at start and end of 
expression', () => {
+  const sqlExpressions = ['revenue + cost'];
+  const columnNames = ['revenue', 'cost'];
+
+  const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
+  expect(found).toEqual(new Set(['revenue', 'cost']));
+});
+
+test('findColumnsInSqlExpressions handles columns in parentheses', () => {
+  const sqlExpressions = ['SUM(amount)', '(price * quantity)'];
+  const columnNames = ['amount', 'price', 'quantity'];
+
+  const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
+  expect(found).toEqual(new Set(['amount', 'price', 'quantity']));
+});
+
+test('findColumnsInSqlExpressions handles special regex characters in column 
names', () => {
+  const sqlExpressions = ['SUM(col.name) + AVG(col$value)'];
+  const columnNames = ['col.name', 'col$value'];
+
+  const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
+  expect(found).toEqual(new Set(['col.name', 'col$value']));
+});
+
+test('findColumnsInSqlExpressions returns empty set when no matches', () => {
+  const sqlExpressions = ['SUM(total)'];
+  const columnNames = ['revenue', 'cost'];
+
+  const found = findColumnsInSqlExpressions(sqlExpressions, columnNames);
+  expect(found.size).toBe(0);
+});
+
+test('findColumnsInSqlExpressions returns empty set with empty inputs', () => {
+  expect(findColumnsInSqlExpressions([], ['col1']).size).toBe(0);
+  expect(findColumnsInSqlExpressions(['SUM(col)'], []).size).toBe(0);
+  expect(findColumnsInSqlExpressions([], []).size).toBe(0);
+});
+
+test('sliceUsesColumn detects columns in SQL expressions', () => {
+  const slice = createMockSlice({
+    metrics: [
+      {
+        expressionType: 'SQL',
+        sqlExpression: 'AVG(orders / customers)',
+        label: 'Avg',
+      },
+    ],
+  });
+
+  expect(sliceUsesColumn(slice, 'orders')).toBe(true);
+  expect(sliceUsesColumn(slice, 'customers')).toBe(true);
+  expect(sliceUsesColumn(slice, 'revenue')).toBe(false);
+});
+
+test('sliceUsesColumn detects explicitly referenced columns', () => {
+  const slice = createMockSlice({
+    groupby: ['category', 'region'],
+  });
+
+  expect(sliceUsesColumn(slice, 'category')).toBe(true);
+  expect(sliceUsesColumn(slice, 'region')).toBe(true);
+  expect(sliceUsesColumn(slice, 'country')).toBe(false);
+});
+
+test('sliceUsesColumn detects columns in both explicit and SQL references', () 
=> {
+  const slice = createMockSlice({
+    groupby: ['category'],
+    metrics: [
+      {
+        expressionType: 'SQL',
+        sqlExpression: 'SUM(revenue)',
+        label: 'Total',
+      },
+    ],
+  });
+
+  expect(sliceUsesColumn(slice, 'category')).toBe(true);
+  expect(sliceUsesColumn(slice, 'revenue')).toBe(true);
+});
diff --git a/superset-frontend/src/dashboard/util/whatIf.ts 
b/superset-frontend/src/dashboard/util/whatIf.ts
index cfe590899f..caed76f04b 100644
--- a/superset-frontend/src/dashboard/util/whatIf.ts
+++ b/superset-frontend/src/dashboard/util/whatIf.ts
@@ -28,6 +28,124 @@ export function isNumericColumn(column: ColumnMeta): 
boolean {
   return column.type_generic === GenericDataType.Numeric;
 }
 
+/**
+ * Collect all SQL expressions from a slice's form_data.
+ * This includes:
+ * - Metrics with expressionType: 'SQL' (sqlExpression)
+ * - Filters with expressionType: 'SQL' (sqlExpression)
+ * - Adhoc columns in groupby, x_axis, series, etc. (sqlExpression)
+ */
+export function collectSqlExpressionsFromSlice(slice: Slice): string[] {
+  const expressions: string[] = [];
+  const formData = slice.form_data;
+  if (!formData) return expressions;
+
+  // Helper to extract sqlExpression from adhoc columns
+  const addAdhocColumnExpression = (col: unknown) => {
+    if (
+      col &&
+      typeof col === 'object' &&
+      'sqlExpression' in col &&
+      typeof (col as { sqlExpression: unknown }).sqlExpression === 'string'
+    ) {
+      expressions.push((col as { sqlExpression: string }).sqlExpression);
+    }
+  };
+
+  // Extract SQL expressions from metrics
+  ensureIsArray(formData.metrics).forEach((metric: unknown) => {
+    if (
+      metric &&
+      typeof metric === 'object' &&
+      'expressionType' in metric &&
+      (metric as { expressionType: unknown }).expressionType === 'SQL' &&
+      'sqlExpression' in metric &&
+      typeof (metric as { sqlExpression: unknown }).sqlExpression === 'string'
+    ) {
+      expressions.push((metric as { sqlExpression: string }).sqlExpression);
+    }
+  });
+
+  // Extract SQL expression from singular metric
+  if (
+    formData.metric &&
+    typeof formData.metric === 'object' &&
+    'expressionType' in formData.metric &&
+    (formData.metric as { expressionType: unknown }).expressionType === 'SQL' 
&&
+    'sqlExpression' in formData.metric &&
+    typeof (formData.metric as { sqlExpression: unknown }).sqlExpression ===
+      'string'
+  ) {
+    expressions.push(
+      (formData.metric as { sqlExpression: string }).sqlExpression,
+    );
+  }
+
+  // Extract SQL expressions from filters
+  ensureIsArray(formData.adhoc_filters).forEach((filter: unknown) => {
+    if (
+      filter &&
+      typeof filter === 'object' &&
+      'expressionType' in filter &&
+      (filter as { expressionType: unknown }).expressionType === 'SQL' &&
+      'sqlExpression' in filter &&
+      typeof (filter as { sqlExpression: unknown }).sqlExpression === 'string'
+    ) {
+      expressions.push((filter as { sqlExpression: string }).sqlExpression);
+    }
+  });
+
+  // Extract SQL expressions from adhoc columns in groupby, x_axis, series, 
columns, entity
+  ensureIsArray(formData.groupby).forEach(addAdhocColumnExpression);
+  ensureIsArray(formData.columns).forEach(addAdhocColumnExpression);
+
+  if (formData.x_axis) {
+    addAdhocColumnExpression(formData.x_axis);
+  }
+  if (formData.series) {
+    addAdhocColumnExpression(formData.series);
+  }
+  if (formData.entity) {
+    addAdhocColumnExpression(formData.entity);
+  }
+
+  return expressions;
+}
+
+/**
+ * Find column names that appear in SQL expressions.
+ * Uses word boundary matching to avoid false positives
+ * (e.g., "order" shouldn't match "order_id" or "reorder").
+ */
+export function findColumnsInSqlExpressions(
+  sqlExpressions: string[],
+  columnNames: string[],
+): Set<string> {
+  const foundColumns = new Set<string>();
+
+  if (sqlExpressions.length === 0 || columnNames.length === 0) {
+    return foundColumns;
+  }
+
+  // Combine all SQL expressions into one string for efficient searching
+  const combinedSql = sqlExpressions.join(' ');
+
+  columnNames.forEach(columnName => {
+    // Use word boundary regex to match exact column names
+    // Escape special regex characters in column name
+    const escapedName = columnName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    // Match column name surrounded by word boundaries or common SQL delimiters
+    const regex = new RegExp(
+      `(^|[^a-zA-Z0-9_])${escapedName}([^a-zA-Z0-9_]|$)`,
+    );
+    if (regex.test(combinedSql)) {
+      foundColumns.add(columnName);
+    }
+  });
+
+  return foundColumns;
+}
+
 /**
  * Extract column names from a slice's form_data
  * This includes columns from groupby, metrics, x_axis, series, filters, etc.
@@ -136,9 +254,27 @@ export function getNumericColumnsForDashboard(
     const datasource = datasources[datasourceKey];
     if (!datasource?.columns) return;
 
-    // Extract columns referenced by this slice
+    // Extract columns explicitly referenced by this slice
     const referencedColumns = extractColumnsFromSlice(slice);
 
+    // Also check SQL expressions for column references
+    const sqlExpressions = collectSqlExpressionsFromSlice(slice);
+    if (sqlExpressions.length > 0) {
+      // Get all numeric column names from this datasource
+      const numericColumnNames = datasource.columns
+        .filter((c: ColumnMeta) => isNumericColumn(c))
+        .map((c: ColumnMeta) => c.column_name);
+
+      // Find which numeric columns are referenced in SQL expressions
+      const sqlReferencedColumns = findColumnsInSqlExpressions(
+        sqlExpressions,
+        numericColumnNames,
+      );
+
+      // Add SQL-referenced columns to the set
+      sqlReferencedColumns.forEach(colName => referencedColumns.add(colName));
+    }
+
     // For each referenced column, check if it's numeric
     referencedColumns.forEach(colName => {
       const colMetadata = datasource.columns.find(
@@ -171,9 +307,23 @@ export function getNumericColumnsForDashboard(
 }
 
 /**
- * Check if a slice uses a specific column
+ * Check if a slice uses a specific column.
+ * Checks both explicitly referenced columns and columns in SQL expressions.
  */
 export function sliceUsesColumn(slice: Slice, columnName: string): boolean {
   const columns = extractColumnsFromSlice(slice);
-  return columns.has(columnName);
+  if (columns.has(columnName)) {
+    return true;
+  }
+
+  // Also check SQL expressions
+  const sqlExpressions = collectSqlExpressionsFromSlice(slice);
+  if (sqlExpressions.length > 0) {
+    const sqlReferencedColumns = findColumnsInSqlExpressions(sqlExpressions, [
+      columnName,
+    ]);
+    return sqlReferencedColumns.has(columnName);
+  }
+
+  return false;
 }

Reply via email to