You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ga...@apache.org on 2026/10/15 21:25:19 UTC

(superset) branch master updated: fix(filters): use manual semantic-view range bounds (#44999)

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

gabotorresruiz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new d548e8dce9c fix(filters): use manual semantic-view range bounds (#44999)
d548e8dce9c is described below

commit d548e8dce9ca191fdc3d85ce2c7750cde52736d7
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Thu Oct 15 17:25:11 2026 -0400

    fix(filters): use manual semantic-view range bounds (#44999)
---
 .../using-superset/semantic-member-selections.mdx  |  10 +
 .../FilterBar/FilterControls/FilterValue.test.tsx  |  53 +++
 .../FilterBar/FilterControls/FilterValue.tsx       |  16 +-
 .../FiltersConfigForm/FiltersConfigForm.tsx        |  22 +-
 .../FiltersConfigForm/getControlItemsMap.test.tsx  |  19 +-
 .../FiltersConfigForm/getControlItemsMap.tsx       |   1 +
 .../FiltersConfigForm/utils.test.ts                |  41 ++
 .../FiltersConfigModal/FiltersConfigForm/utils.ts  |  27 +-
 .../FiltersConfigModal/FiltersConfigModal.test.tsx |  40 ++
 .../filters/components/Range/RangeChart.test.tsx   | 115 +++++
 .../components/Range/RangeFilterPlugin.test.tsx    | 475 ++++++++++++++++++++-
 .../filters/components/Range/RangeFilterPlugin.tsx | 195 ++++++---
 .../filters/components/Range/buildQuery.test.ts    |  50 +++
 .../src/filters/components/Range/buildQuery.ts     |   4 +
 .../src/filters/components/Range/index.ts          |   2 +
 .../filters/components/Range/isSemanticRange.ts    |  32 ++
 .../src/filters/components/Range/transformProps.ts |   2 +-
 .../src/utils/semanticViewStructure.test.ts        |  50 +++
 .../src/utils/semanticViewStructure.ts             |   2 +-
 superset/translations/messages.pot                 |   3 +
 20 files changed, 1083 insertions(+), 76 deletions(-)

diff --git a/docs/docs/using-superset/semantic-member-selections.mdx b/docs/docs/using-superset/semantic-member-selections.mdx
index a901fee7423..4a1aa2774d6 100644
--- a/docs/docs/using-superset/semantic-member-selections.mdx
+++ b/docs/docs/using-superset/semantic-member-selections.mdx
@@ -102,3 +102,13 @@ predicates. Provider adapters must be upgraded before, or together with, the
 host/SDK so that mixed selections preserve missing-value rows.
 
 For adapter authors, see the [semantic filter contract](/developer-docs/extensions/semantic-filters).
+
+## Numerical range filters
+
+For semantic views, numerical range filters use **Minimum value** and
+**Maximum value** inputs instead of a slider. No query is sent to discover
+bounds. Leave either input empty for an unbounded end; leave both empty to
+remove the constraint unless **Filter value is required** is enabled.
+The minimum cannot exceed the maximum. Negative values and decimals are
+supported, and existing minimum, maximum, and exact single-value modes remain
+available. SQL dataset filters retain their discovered bounds and slider options.
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx
index 4d9c9c7ef64..2fc6873948e 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx
@@ -135,6 +135,29 @@ beforeEach(() => {
   );
 });
 
+test('semantic range renders without fetching bounds', async () => {
+  mockUseTransitiveParentIds.mockReturnValue([]);
+  mockUseFilterDependencies.mockReturnValue({});
+  mockRequestChartData.mockResolvedValue([{ data: [{ min: 1, max: 2 }] }]);
+  renderFilterValue({
+    filter: createMockFilter({
+      filterType: 'filter_range',
+      targets: [
+        {
+          datasetId: 3,
+          datasourceType: DatasourceType.SemanticView,
+          column: { name: 'Orders.age' },
+        },
+      ],
+    }),
+  });
+  await waitFor(() =>
+    expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument(),
+  );
+  expect(mockRequestChartData).not.toHaveBeenCalled();
+  expect(screen.queryByRole('status')).not.toBeInTheDocument();
+});
+
 test('renders loading spinner when filter has a data source', () => {
   mockRequestChartData.mockReturnValue(new Promise(() => {}));
 
@@ -447,3 +470,33 @@ test('legacy permalink values require an explicit reset before the plugin can em
     },
   });
 });
+
+test('semantic select still requests chart data for its options', async () => {
+  mockUseTransitiveParentIds.mockReturnValue([]);
+  mockUseFilterDependencies.mockReturnValue({});
+  mockRequestChartData.mockResolvedValue([
+    { data: [{ 'Orders.status': 'Open' }] },
+  ]);
+  renderFilterValue({
+    filter: createMockFilter({
+      filterType: 'filter_select',
+      targets: [
+        {
+          datasetId: 3,
+          datasourceType: DatasourceType.SemanticView,
+          column: { name: 'Orders.status' },
+        },
+      ],
+    }),
+  });
+  await waitFor(() => expect(mockRequestChartData).toHaveBeenCalled());
+  expect(mockRequestChartData.mock.calls[0][0].formData).toEqual(
+    expect.objectContaining({
+      datasource: '3__semantic_view',
+      viz_type: 'filter_select',
+    }),
+  );
+  await waitFor(() =>
+    expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument(),
+  );
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
index 71849c6bc5d..a10aafd498f 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
@@ -61,6 +61,7 @@ import {
   unsetHoveredChartCustomization,
 } from 'src/dashboard/actions/nativeFilters';
 import { RESPONSIVE_WIDTH } from 'src/filters/components/common';
+import isSemanticRange from 'src/filters/components/Range/isSemanticRange';
 import { dispatchHoverAction, dispatchFocusAction } from './utils';
 import { FilterControlProps } from './types';
 import { getFormData } from '../../utils';
@@ -286,12 +287,15 @@ const FilterValue: FC<FilterValueProps> = ({
         return;
       }
       setIsRefreshing(true);
-      requestChartDataResolved({
-        formData: newFormData,
-        force: shouldRefresh,
-        ownState: filterOwnState,
-        requestParams: { async_mode_override: asyncModeOverride },
-      })
+      const query = isSemanticRange(newFormData)
+        ? Promise.resolve(queriesDataPlaceholder)
+        : requestChartDataResolved({
+            formData: newFormData,
+            force: shouldRefresh,
+            ownState: filterOwnState,
+            requestParams: { async_mode_override: asyncModeOverride },
+          });
+      query
         .then(queriesResponse => {
           setState(queriesResponse as ChartDataResponseResult[]);
           setError(undefined);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
index 5144976fae7..ec9b34a899a 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
@@ -63,6 +63,7 @@ import {
 } from 'src/filters/components/Select/types';
 import { useSelector } from 'react-redux';
 import { requestChartDataResolved } from 'src/components/Chart/chartAction';
+import isSemanticRange from 'src/filters/components/Range/isSemanticRange';
 import {
   Constants,
   Button,
@@ -572,6 +573,7 @@ const FiltersConfigForm = (
         dashboardId,
         groupby: formFilter?.column,
         ...formFilter,
+        filterType: itemTypeField,
       });
       formData.extra_form_data = dependenciesDefaultValues;
 
@@ -579,11 +581,15 @@ const FiltersConfigForm = (
         defaultValueQueriesData: null,
         isDataDirty: false,
       });
-      requestChartDataResolved({
-        formData,
-        force,
-        requestParams: { async_mode_override: asyncModeOverride },
-      })
+      // Manual semantic ranges need no provider bounds, including defaults.
+      const query = isSemanticRange(formData)
+        ? Promise.resolve([{ data: [{}] }])
+        : requestChartDataResolved({
+            formData,
+            force,
+            requestParams: { async_mode_override: asyncModeOverride },
+          });
+      query
         .then(queriesResponse => {
           setNativeFilterFieldValuesWrapper({
             defaultValueQueriesData:
@@ -598,6 +604,8 @@ const FiltersConfigForm = (
     },
     [
       datasetId,
+      datasourceType,
+      itemTypeField,
       dashboardId,
       filterId,
       forceUpdate,
@@ -1974,6 +1982,10 @@ const FiltersConfigForm = (
 
                                     <FormItem
                                       name={['filters', filterId, 'rangeType']}
+                                      hidden={
+                                        datasourceType ===
+                                        DatasourceType.SemanticView
+                                      }
                                     >
                                       <StyledRowFormItem
                                         expanded={expanded}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
index 99adca28e22..079dec1cf15 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Filter, NativeFilterType } from '@superset-ui/core';
+import { DatasourceType, Filter, NativeFilterType } from '@superset-ui/core';
 import { render, screen, userEvent } from 'spec/helpers/testing-library';
 import type { FormInstance } from '@superset-ui/core/components';
 import getControlItemsMap, { ControlItemsProps } from './getControlItemsMap';
@@ -262,4 +262,21 @@ describe('ColumnSelect filterValues behavior', () => {
     expect(screen.queryByText('col3')).not.toBeInTheDocument();
     expect(screen.queryByText('col2')).not.toBeInTheDocument();
   });
+  test('passes the datasource type so semantic range filters can require numeric columns', () => {
+    (doesColumnMatchFilterType as jest.Mock).mockReturnValue(true);
+    const props = {
+      ...createProps(),
+      datasourceType: DatasourceType.SemanticView,
+      formFilter: { filterType: 'filter_range' },
+    };
+    // @ts-expect-error: bypass incomplete formFilter type for test
+    const element = getControlItemsMap(props).mainControlItems.groupby
+      .element as React.ReactElement;
+    render(element);
+    expect(doesColumnMatchFilterType).toHaveBeenCalledWith(
+      'filter_range',
+      expect.objectContaining({ name: 'col1' }),
+      DatasourceType.SemanticView,
+    );
+  });
 });
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
index 9404d5c9019..a5d5b7969a4 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
@@ -147,6 +147,7 @@ export default function getControlItemsMap({
                 doesColumnMatchFilterType(
                   formFilter?.filterType || '',
                   column,
+                  datasourceType,
                 ) && !!column?.filterable
               }
               onChange={() => {
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts
index 70334eea1e5..3ed7c20d909 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts
@@ -293,6 +293,47 @@ test('doesColumnMatchFilterType returns false when column type does not match fi
   expect(doesColumnMatchFilterType('filter_range', stringColumn)).toBe(false);
 });
 
+test('doesColumnMatchFilterType offers only numeric semantic dimensions to filter_range', () => {
+  const numeric = createColumn('revenue', GenericDataType.Numeric);
+  const untyped = createColumn('payload');
+  const string = createColumn('region', GenericDataType.String);
+  const temporal = createColumn('ordered_at', GenericDataType.Temporal);
+  const semantic = DatasourceType.SemanticView;
+  expect(doesColumnMatchFilterType('filter_range', numeric, semantic)).toBe(
+    true,
+  );
+  // A semantic range filter has no runtime non-numeric check, so an
+  // unrecognised type must not be offered for >= / <= comparisons.
+  expect(doesColumnMatchFilterType('filter_range', untyped, semantic)).toBe(
+    false,
+  );
+  expect(doesColumnMatchFilterType('filter_range', string, semantic)).toBe(
+    false,
+  );
+  expect(doesColumnMatchFilterType('filter_range', temporal, semantic)).toBe(
+    false,
+  );
+});
+
+test('doesColumnMatchFilterType keeps untyped dataset columns for filter_range', () => {
+  const untyped = createColumn('payload');
+  expect(
+    doesColumnMatchFilterType('filter_range', untyped, DatasourceType.Table),
+  ).toBe(true);
+  expect(doesColumnMatchFilterType('filter_range', untyped)).toBe(true);
+});
+
+test('doesColumnMatchFilterType keeps untyped semantic dimensions for other filters', () => {
+  const untyped = createColumn('payload');
+  expect(
+    doesColumnMatchFilterType(
+      'filter_select',
+      untyped,
+      DatasourceType.SemanticView,
+    ),
+  ).toBe(true);
+});
+
 test('doesColumnMatchFilterType returns true when column type matches filter_time', () => {
   const temporalColumn = createColumn('created_at', GenericDataType.Temporal);
   expect(doesColumnMatchFilterType('filter_time', temporalColumn)).toBe(true);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts
index 33fa45568fa..91647a70db0 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts
@@ -94,12 +94,27 @@ export const shouldShowTimeRangePicker = (
   currentDataset: (Dataset & { column_types: GenericDataType[] }) | undefined,
 ): boolean => (currentDataset ? hasTemporalColumns(currentDataset) : true);
 
-export const doesColumnMatchFilterType = (filterType: string, column: Column) =>
-  !column.type_generic ||
-  !(filterType in FILTER_SUPPORTED_TYPES) ||
-  FILTER_SUPPORTED_TYPES[
-    filterType as keyof typeof FILTER_SUPPORTED_TYPES
-  ]?.includes(column.type_generic);
+export const doesColumnMatchFilterType = (
+  filterType: string,
+  column: Column,
+  datasourceType?: DatasourceType,
+): boolean => {
+  // Semantic range filters skip the runtime non-numeric bounds check, so an
+  // unrecognised dimension type must not be offered for >= / <= comparisons.
+  if (
+    filterType === 'filter_range' &&
+    datasourceType === DatasourceType.SemanticView
+  ) {
+    return column.type_generic === GenericDataType.Numeric;
+  }
+  return (
+    !column.type_generic ||
+    !(filterType in FILTER_SUPPORTED_TYPES) ||
+    !!FILTER_SUPPORTED_TYPES[
+      filterType as keyof typeof FILTER_SUPPORTED_TYPES
+    ]?.includes(column.type_generic)
+  );
+};
 
 // Shared semantic-view structure helpers live in a layer-neutral module so
 // non-dashboard consumers (e.g. the dataset drill-info hook) need not import
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
index 0db87c7d7a2..8498d6e7859 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
@@ -1172,6 +1172,46 @@ test('toggles "Filter has default value" to show and hide the Default Value cont
   });
 });
 
+test('semantic range defaults use manual inputs without a chart-data bounds request', async () => {
+  fetchMock.clearHistory();
+  fetchMock.get('glob:*/api/v1/semantic_view/988/structure', {
+    result: {
+      name: 'Orders',
+      dimensions: [{ name: 'Orders.age', type: 'number' }],
+      metrics: [],
+    },
+  });
+  const filter = {
+    ...buildNativeFilter('NATIVE_FILTER-range', 'Age', []),
+    filterType: 'filter_range',
+    targets: [
+      {
+        datasetId: 988,
+        datasourceType: DatasourceType.SemanticView,
+        column: { name: 'Orders.age' },
+      },
+    ],
+    controlValues: { enableSingleValue: undefined },
+    defaultDataMask: { filterState: { value: [18, 65] }, extraFormData: {} },
+  };
+  const state = {
+    ...defaultState(),
+    dashboardInfo: { metadata: { native_filter_configuration: [filter] } },
+    dashboardLayout,
+  };
+  defaultRender(state, { ...props, createNewOnOpen: false });
+  expect(
+    await screen.findByRole('spinbutton', { name: 'Minimum value' }),
+  ).toHaveValue('18');
+  expect(screen.getByRole('spinbutton', { name: 'Maximum value' })).toHaveValue(
+    '65',
+  );
+  expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+  expect(fetchMock.callHistory.calls('glob:*/api/v1/chart/data')).toHaveLength(
+    0,
+  );
+}, 30000);
+
 test('semantic filter reset requires reselection and survives save and reopen', async () => {
   fetchMock.get('glob:*/api/v1/semantic_view/987/structure', {
     result: {
diff --git a/superset-frontend/src/filters/components/Range/RangeChart.test.tsx b/superset-frontend/src/filters/components/Range/RangeChart.test.tsx
new file mode 100644
index 00000000000..e08564210fd
--- /dev/null
+++ b/superset-frontend/src/filters/components/Range/RangeChart.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * 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 {
+  fireEvent,
+  render,
+  screen,
+  waitFor,
+} from 'spec/helpers/testing-library';
+import { GenericDataType } from '@apache-superset/core/common';
+import ChartRenderer, {
+  type ChartRendererProps,
+} from 'src/components/Chart/ChartRenderer';
+import { ChartSource } from 'src/types/ChartSource';
+import RangeFilterPlugin from './index';
+
+const plugin = new RangeFilterPlugin().configure({ key: 'filter_range' });
+
+beforeAll(() => plugin.register());
+afterAll(() => plugin.unregister());
+
+function chartProps(datasource: string): ChartRendererProps {
+  return {
+    chartId: 17,
+    chartStatus: 'success',
+    width: 700,
+    height: 400,
+    vizType: 'filter_range',
+    source: ChartSource.Explore,
+    formData: {
+      viz_type: 'filter_range',
+      datasource,
+      groupby: ['amount'],
+      enableEmptyFilter: false,
+      rangeDisplayMode: 'slider',
+    },
+    filterState: { value: [null, null] },
+    queriesResponse: [],
+    actions: {
+      chartRenderingSucceeded: jest.fn(),
+      chartRenderingFailed: jest.fn(),
+      logEvent: jest.fn(),
+      updateDataMask: jest.fn(),
+    },
+  };
+}
+
+test('standalone semantic range renders manual inputs through the chart wrapper with no query results', async () => {
+  const props = chartProps('1__semantic_view');
+  render(<ChartRenderer {...props} />, { useRedux: true });
+  const inputs = await screen.findAllByRole('spinbutton');
+  expect(inputs).toHaveLength(2);
+  expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+  expect(
+    screen.queryByText('No results were returned for this query'),
+  ).not.toBeInTheDocument();
+  fireEvent.change(inputs[0], { target: { value: '5' } });
+  fireEvent.change(inputs[1], { target: { value: '7' } });
+  await waitFor(() =>
+    expect(props.actions.updateDataMask).toHaveBeenLastCalledWith(
+      17,
+      expect.objectContaining({
+        extraFormData: expect.objectContaining({
+          filters: [
+            { col: 'amount', op: '>=', val: 5 },
+            { col: 'amount', op: '<=', val: 7 },
+          ],
+        }),
+      }),
+    ),
+  );
+});
+
+test('standalone SQL range keeps its slider when bounds exist', async () => {
+  const props = chartProps('1__table');
+  render(
+    <ChartRenderer
+      {...props}
+      queriesResponse={
+        [
+          {
+            data: [{ min: 3, max: 10 }],
+            colnames: ['min', 'max'],
+            coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
+            rowcount: 1,
+          },
+        ] as ChartRendererProps['queriesResponse']
+      }
+    />,
+    { useRedux: true },
+  );
+  expect(await screen.findAllByRole('slider')).toHaveLength(2);
+  expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+});
+
+test('standalone SQL range keeps its empty-result inputs usable', async () => {
+  render(<ChartRenderer {...chartProps('1__table')} />, { useRedux: true });
+  expect(await screen.findAllByRole('spinbutton')).toHaveLength(2);
+  expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
index 39af8cdfd04..3ac0e8fa474 100644
--- a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
+++ b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
@@ -19,7 +19,7 @@
 import { AppSection, type ChartProps } from '@superset-ui/core';
 import { GenericDataType } from '@apache-superset/core/common';
 import userEvent from '@testing-library/user-event';
-import { render, screen } from 'spec/helpers/testing-library';
+import { render, screen, fireEvent } from 'spec/helpers/testing-library';
 import RangeFilterPlugin, { calculateStep } from './RangeFilterPlugin';
 import { RangeDisplayMode, type PluginFilterRangeProps } from './types';
 import { SingleValueType } from './SingleValueType';
@@ -288,6 +288,26 @@ describe('RangeFilterPlugin', () => {
       expect(screen.queryAllByRole('slider')).toHaveLength(0);
     });
 
+    test('dataset input mode emits an exact filter for a typed exact value', () => {
+      getWrapper({
+        formData: {
+          rangeDisplayMode: RangeDisplayMode.Input,
+          enableSingleValue: SingleValueType.Exact,
+        },
+        filterState: { value: [10, 10] },
+      });
+      fireEvent.change(screen.getByRole('spinbutton'), {
+        target: { value: '42' },
+      });
+      expect(setDataMask).toHaveBeenLastCalledWith(
+        expect.objectContaining({
+          extraFormData: {
+            filters: [{ col: 'SP_POP_TOTL', op: '==', val: 42 }],
+          },
+        }),
+      );
+    });
+
     test('should render both slider and inputs in slider-and-input mode', () => {
       getWrapper({
         formData: {
@@ -503,7 +523,460 @@ test('calculateStep never returns 0 for tiny ranges', () => {
   expect(calculateStep(0, 0.0000001)).toBeGreaterThan(0);
 });
 
+test('semantic range renders labelled manual bounds without query results or a slider', async () => {
+  const setDataMask = jest.fn();
+  render(
+    <RangeFilterPlugin
+      {...(transformProps({
+        ...rangeProps,
+        queriesData: [],
+        formData: {
+          ...rangeProps.formData,
+          datasource: '3__semantic_view',
+          defaultValue: undefined,
+          rangeDisplayMode: RangeDisplayMode.Slider,
+        },
+        filterState: { value: [null, null] },
+      } as unknown as ChartProps) as PluginFilterRangeProps)}
+      setDataMask={setDataMask}
+    />,
+  );
+  const lower = screen.getByRole('spinbutton', { name: 'Minimum value' });
+  const upper = screen.getByRole('spinbutton', { name: 'Maximum value' });
+  expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+  expect(
+    screen.getByText('Leave a bound empty for an unbounded range.'),
+  ).toBeInTheDocument();
+  expect(lower).toHaveValue('');
+  expect(upper).toHaveValue('');
+  fireEvent.change(lower, { target: { value: '-1.5' } });
+  fireEvent.blur(lower);
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '>=', val: -1.5 }] },
+      filterState: expect.objectContaining({
+        value: [-1.5, null],
+        validateStatus: undefined,
+      }),
+    }),
+  );
+  fireEvent.change(upper, { target: { value: '2000000.25' } });
+  fireEvent.blur(upper);
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: {
+        filters: [
+          { col: 'SP_POP_TOTL', op: '>=', val: -1.5 },
+          { col: 'SP_POP_TOTL', op: '<=', val: 2000000.25 },
+        ],
+      },
+    }),
+  );
+  await userEvent.clear(lower);
+  await userEvent.clear(upper);
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: {},
+      filterState: expect.objectContaining({
+        value: [null, null],
+        validateStatus: undefined,
+      }),
+    }),
+  );
+});
+
 test('calculateStep falls back for non-positive ranges', () => {
   expect(calculateStep(5, 5)).toBe(0.01);
   expect(calculateStep(10, 5)).toBe(0.01);
 });
+
+function semanticRangeProps(
+  overrides: Partial<PluginFilterRangeProps> = {},
+): PluginFilterRangeProps {
+  return {
+    ...(transformProps({
+      ...rangeProps,
+      queriesData: [],
+      formData: {
+        ...rangeProps.formData,
+        datasource: '3__semantic_view',
+        defaultValue: undefined,
+      },
+      filterState: { value: [null, null] },
+    } as unknown as ChartProps) as PluginFilterRangeProps),
+    ...overrides,
+  };
+}
+
+test('semantic range rejects reversed bounds and recovers when corrected', () => {
+  const setDataMask = jest.fn();
+  render(
+    <RangeFilterPlugin {...semanticRangeProps()} setDataMask={setDataMask} />,
+  );
+  const [lower, upper] = screen.getAllByRole('spinbutton');
+  fireEvent.change(lower, { target: { value: '20' } });
+  fireEvent.change(upper, { target: { value: '10' } });
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: {},
+      filterState: expect.objectContaining({ validateStatus: 'error' }),
+    }),
+  );
+  expect(
+    screen.getByText('Min value cannot be greater than max value'),
+  ).toBeInTheDocument();
+  fireEvent.change(upper, { target: { value: '30' } });
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      filterState: expect.objectContaining({
+        value: [20, 30],
+        validateStatus: undefined,
+      }),
+    }),
+  );
+});
+
+test('semantic range validates required empty saved state without discovered bounds', () => {
+  const props = semanticRangeProps();
+  const setDataMask = jest.fn();
+  render(
+    <RangeFilterPlugin
+      {...props}
+      formData={{ ...props.formData, enableEmptyFilter: true }}
+      setDataMask={setDataMask}
+    />,
+  );
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: {},
+      filterState: expect.objectContaining({ validateStatus: 'error' }),
+    }),
+  );
+});
+
+test.each([
+  [SingleValueType.Minimum, [5, null], '>='],
+  [SingleValueType.Maximum, [null, 5], '<='],
+  [SingleValueType.Exact, [5, 5], '=='],
+] as const)(
+  'semantic single-value mode %s preserves saved defaults and emits the right operator',
+  (mode, value, op) => {
+    const props = semanticRangeProps();
+    const setDataMask = jest.fn();
+    render(
+      <RangeFilterPlugin
+        {...props}
+        formData={{
+          ...props.formData,
+          enableSingleValue: mode,
+          defaultValue: [...value],
+        }}
+        filterState={{ value: [...value] }}
+        setDataMask={setDataMask}
+      />,
+    );
+    expect(screen.getAllByRole('spinbutton')).toHaveLength(1);
+    expect(screen.getByRole('spinbutton')).toHaveValue('5');
+    expect(setDataMask).toHaveBeenLastCalledWith(
+      expect.objectContaining({
+        extraFormData: { filters: [{ col: 'SP_POP_TOTL', op, val: 5 }] },
+      }),
+    );
+    fireEvent.change(screen.getByRole('spinbutton'), {
+      target: { value: '-2.5' },
+    });
+    expect(setDataMask).toHaveBeenLastCalledWith(
+      expect.objectContaining({
+        extraFormData: { filters: [{ col: 'SP_POP_TOTL', op, val: -2.5 }] },
+      }),
+    );
+  },
+);
+
+test('semantic range restores external values and clears a saved default', () => {
+  const props = semanticRangeProps();
+  const setDataMask = jest.fn();
+  const element = (value: [number | null, number | null]) => (
+    <RangeFilterPlugin
+      {...props}
+      formData={{ ...props.formData, defaultValue: [10, 70] }}
+      filterState={{ value }}
+      setDataMask={setDataMask}
+    />
+  );
+  const { rerender } = render(element([10, 70]));
+  rerender(element([null, null]));
+  expect(
+    screen
+      .getAllByRole('spinbutton')
+      .map(input => (input as HTMLInputElement).value),
+  ).toEqual(['', '']);
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({ extraFormData: {} }),
+  );
+  rerender(element([0, 0]));
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '==', val: 0 }] },
+    }),
+  );
+});
+
+test('semantic range clears a required-value error when made optional', () => {
+  const props = semanticRangeProps();
+  const setDataMask = jest.fn();
+  const element = (required: boolean, invalid: boolean) => (
+    <RangeFilterPlugin
+      {...props}
+      formData={{ ...props.formData, enableEmptyFilter: required }}
+      filterState={{
+        value: [null, null],
+        validateStatus: invalid ? 'error' : undefined,
+        validateMessage: invalid
+          ? 'Please provide a valid min or max value'
+          : '',
+      }}
+      setDataMask={setDataMask}
+    />
+  );
+  const { rerender } = render(element(true, false));
+  rerender(element(true, true));
+  setDataMask.mockClear();
+  rerender(element(false, true));
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: {},
+      filterState: expect.objectContaining({
+        value: [null, null],
+        validateStatus: undefined,
+      }),
+    }),
+  );
+  expect(
+    screen.queryByText('Please provide a valid min or max value'),
+  ).not.toBeInTheDocument();
+});
+
+test.each([undefined, SingleValueType.Exact])(
+  'semantic range rejects non-finite manual input in mode %s',
+  mode => {
+    const props = semanticRangeProps();
+    const setDataMask = jest.fn();
+    render(
+      <RangeFilterPlugin
+        {...props}
+        formData={{ ...props.formData, enableSingleValue: mode }}
+        setDataMask={setDataMask}
+      />,
+    );
+    fireEvent.change(
+      screen.getAllByRole('spinbutton')[mode === undefined ? 1 : 0],
+      { target: { value: '9'.repeat(309) } },
+    );
+    expect(setDataMask).toHaveBeenLastCalledWith(
+      expect.objectContaining({
+        extraFormData: {},
+        filterState: expect.objectContaining({ validateStatus: 'error' }),
+      }),
+    );
+  },
+);
+
+test.each([
+  ['an all-null numeric column', [{ min: null, max: null }]],
+  ['an empty result', []],
+])('keeps unbounded inputs usable for %s', (_label, data) => {
+  const setDataMask = jest.fn();
+  render(
+    <RangeFilterPlugin
+      {...(transformProps({
+        ...rangeProps,
+        queriesData: [{ ...rangeProps.queriesData[0], rowcount: 0, data }],
+        formData: { ...rangeProps.formData, defaultValue: undefined },
+        filterState: { value: [null, null] },
+      } as unknown as ChartProps) as PluginFilterRangeProps)}
+      setDataMask={setDataMask}
+    />,
+  );
+  expect(
+    screen.queryByText('Chosen non-numeric column'),
+  ).not.toBeInTheDocument();
+  expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+  const [lower, upper] = screen.getAllByRole('spinbutton');
+  expect(lower).toHaveAttribute('placeholder', '');
+  expect(upper).toHaveAttribute('placeholder', '');
+  expect(
+    screen.getByText('Leave a bound empty for an unbounded range.'),
+  ).toBeInTheDocument();
+  fireEvent.change(lower, { target: { value: '-3' } });
+  fireEvent.blur(lower);
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '>=', val: -3 }] },
+    }),
+  );
+});
+
+test('flags a column whose aggregates are not numeric', () => {
+  render(
+    <RangeFilterPlugin
+      {...(transformProps({
+        ...rangeProps,
+        queriesData: [
+          { ...rangeProps.queriesData[0], data: [{ min: 'a', max: 'z' }] },
+        ],
+      } as unknown as ChartProps) as PluginFilterRangeProps)}
+      setDataMask={jest.fn()}
+    />,
+  );
+  expect(screen.getByText('Chosen non-numeric column')).toBeInTheDocument();
+});
+
+test.each([
+  SingleValueType.Minimum,
+  SingleValueType.Maximum,
+  SingleValueType.Exact,
+])('optional semantic single-value mode %s accepts Clear All', mode => {
+  const props = semanticRangeProps();
+  const setDataMask = jest.fn();
+  const element = (value: [number | null, number | null]) => (
+    <RangeFilterPlugin
+      {...props}
+      formData={{
+        ...props.formData,
+        enableSingleValue: mode,
+        enableEmptyFilter: false,
+      }}
+      filterState={{ value }}
+      setDataMask={setDataMask}
+    />
+  );
+  const { rerender } = render(element([5, 5]));
+  rerender(element([null, null]));
+  expect(screen.getByRole('spinbutton')).toHaveValue('');
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: {},
+      filterState: expect.objectContaining({
+        value: [null, null],
+        validateStatus: undefined,
+      }),
+    }),
+  );
+  expect(
+    screen.queryByText('Please provide a valid min or max value'),
+  ).not.toBeInTheDocument();
+});
+
+test.each([
+  SingleValueType.Minimum,
+  SingleValueType.Maximum,
+  SingleValueType.Exact,
+])('required semantic single-value mode %s rejects Clear All', mode => {
+  const props = semanticRangeProps();
+  const setDataMask = jest.fn();
+  const element = (value: [number | null, number | null]) => (
+    <RangeFilterPlugin
+      {...props}
+      formData={{
+        ...props.formData,
+        enableSingleValue: mode,
+        enableEmptyFilter: true,
+      }}
+      filterState={{ value }}
+      setDataMask={setDataMask}
+    />
+  );
+  const { rerender } = render(element([5, 5]));
+  rerender(element([null, null]));
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: {},
+      filterState: expect.objectContaining({ validateStatus: 'error' }),
+    }),
+  );
+  expect(
+    screen.getByText('Please provide a valid min or max value'),
+  ).toBeInTheDocument();
+});
+
+test.each([
+  ['an empty SQL column', []],
+  ['an all-null SQL column', [{ min: null, max: null }]],
+])('saved Slider mode falls back to usable inputs for %s', (_label, data) => {
+  const setDataMask = jest.fn();
+  render(
+    <RangeFilterPlugin
+      {...(transformProps({
+        ...rangeProps,
+        queriesData: [{ ...rangeProps.queriesData[0], rowcount: 0, data }],
+        formData: {
+          ...rangeProps.formData,
+          rangeDisplayMode: RangeDisplayMode.Slider,
+          defaultValue: undefined,
+        },
+        filterState: { value: [null, null] },
+      } as unknown as ChartProps) as PluginFilterRangeProps)}
+      setDataMask={setDataMask}
+    />,
+  );
+  expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+  const [lower, upper] = screen.getAllByRole('spinbutton');
+  expect(lower).toBeInTheDocument();
+  expect(upper).toBeInTheDocument();
+  fireEvent.change(lower, { target: { value: '-3' } });
+  fireEvent.blur(lower);
+  expect(setDataMask).toHaveBeenLastCalledWith(
+    expect.objectContaining({
+      extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '>=', val: -3 }] },
+    }),
+  );
+});
+
+test.each(
+  [
+    SingleValueType.Minimum,
+    SingleValueType.Maximum,
+    SingleValueType.Exact,
+  ].flatMap(mode => [false, true].map(required => ({ mode, required }))),
+)(
+  'SQL single-value mode $mode validates cleared input with required=$required',
+  async ({ mode, required }) => {
+    const setDataMask = jest.fn();
+    const props = transformProps({
+      ...rangeProps,
+      formData: {
+        ...rangeProps.formData,
+        datasource: '3__table',
+        enableSingleValue: mode,
+        enableEmptyFilter: required,
+      },
+    } as unknown as ChartProps) as PluginFilterRangeProps;
+    render(<RangeFilterPlugin {...props} setDataMask={setDataMask} />);
+    await userEvent.clear(screen.getByRole('spinbutton'));
+    if (required) {
+      expect(setDataMask).toHaveBeenLastCalledWith(
+        expect.objectContaining({
+          extraFormData: {},
+          filterState: expect.objectContaining({ validateStatus: 'error' }),
+        }),
+      );
+      expect(
+        screen.getByText('Please provide a valid min or max value'),
+      ).toBeInTheDocument();
+    } else {
+      expect(setDataMask).toHaveBeenLastCalledWith(
+        expect.objectContaining({
+          extraFormData: {},
+          filterState: expect.objectContaining({
+            value: [null, null],
+            validateStatus: undefined,
+          }),
+        }),
+      );
+      expect(
+        screen.queryByText('Please provide a valid min or max value'),
+      ).not.toBeInTheDocument();
+    }
+  },
+);
diff --git a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
index a414491d6bc..157e5489c37 100644
--- a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
+++ b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
@@ -36,6 +36,7 @@ import { PluginFilterRangeProps, RangeDisplayMode } from './types';
 import { StatusMessage, FilterPluginStyle } from '../common';
 import { getRangeExtraFormData } from '../../utils';
 import { SingleValueType } from './SingleValueType';
+import isSemanticRange from './isSemanticRange';
 
 type InputValue = number | null;
 type RangeValue = [InputValue, InputValue];
@@ -163,8 +164,8 @@ export const calculateStep = (minValue: number, maxValue: number): number => {
 
 const validateRange = (
   values: RangeValue,
-  min: number,
-  max: number,
+  min: number | undefined,
+  max: number | undefined,
   enableEmptyFilter: boolean,
   enableSingleValue?: SingleValueType,
 ): { isValid: boolean; errorMessage: string | null } => {
@@ -176,6 +177,23 @@ const validateRange = (
     max,
   });
 
+  if (values.some(value => value !== null && !Number.isFinite(value))) {
+    return { isValid: false, errorMessage: requiredError };
+  }
+
+  // Allow empty ranges if enableEmptyFilter is false
+  if (!enableEmptyFilter && inputMin === null && inputMax === null) {
+    return { isValid: true, errorMessage: null };
+  }
+
+  // If enableEmptyFilter is true, at least one value is required
+  if (enableEmptyFilter && inputMin === null && inputMax === null) {
+    return {
+      isValid: false,
+      errorMessage: requiredError,
+    };
+  }
+
   // Single value validation
   if (enableSingleValue !== undefined) {
     const isSingleMin =
@@ -187,7 +205,10 @@ const validateRange = (
       return { isValid: false, errorMessage: requiredError };
     }
 
-    if (isNumber(value) && (value < min || value > max)) {
+    if (
+      (min !== undefined && value < min) ||
+      (max !== undefined && value > max)
+    ) {
       return { isValid: false, errorMessage: rangeError };
     }
 
@@ -195,19 +216,6 @@ const validateRange = (
   }
 
   // Range validation
-  // Allow empty ranges if enableEmptyFilter is false
-  if (!enableEmptyFilter && inputMin === null && inputMax === null) {
-    return { isValid: true, errorMessage: null };
-  }
-
-  // If enableEmptyFilter is true, at least one value is required
-  if (enableEmptyFilter && inputMin === null && inputMax === null) {
-    return {
-      isValid: false,
-      errorMessage: requiredError,
-    };
-  }
-
   // Check relationship between min and max when both are provided
   if (inputMin !== null && inputMax !== null && inputMin > inputMax) {
     return {
@@ -218,8 +226,8 @@ const validateRange = (
 
   //   Check individual value bounds if provided
   if (
-    (inputMin !== null && inputMin < min) ||
-    (inputMax !== null && inputMax > max)
+    (inputMin !== null && min !== undefined && inputMin < min) ||
+    (inputMax !== null && max !== undefined && inputMax > max)
   ) {
     return {
       isValid: false,
@@ -249,9 +257,19 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
     isOverflowingFilterBar,
   } = props;
 
+  const manualBounds = isSemanticRange(formData);
   const [row] = data;
-  // @ts-expect-error
-  const { min, max }: { min: number; max: number } = row;
+  const min =
+    !manualBounds && typeof row?.min === 'number' ? row.min : undefined;
+  const max =
+    !manualBounds && typeof row?.max === 'number' ? row.max : undefined;
+  // Null aggregates (empty or all-null column) leave the range unbounded;
+  // only a present, non-numeric aggregate marks the column unusable.
+  const isNonNumericBound = (value: unknown) =>
+    value !== null && value !== undefined && typeof value !== 'number';
+  const nonNumericColumn =
+    !manualBounds &&
+    (isNonNumericBound(row?.min) || isNonNumericBound(row?.max));
 
   const sliderStep = useMemo(
     () =>
@@ -263,7 +281,9 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
 
   // Get the display mode from formData
   const rangeDisplayMode =
-    formData?.rangeDisplayMode || RangeDisplayMode.SliderAndInput;
+    manualBounds || min === undefined || max === undefined
+      ? RangeDisplayMode.Input
+      : formData?.rangeDisplayMode || RangeDisplayMode.SliderAndInput;
 
   const minIndex = 0;
   const maxIndex = 1;
@@ -279,6 +299,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
 
   // Prepare slider value from input value, converting nulls to min/max
   const sliderValue = useMemo(() => {
+    if (min === undefined || max === undefined) return [];
     const [inputMin, inputMax] = inputValue;
 
     // For single value filters
@@ -320,7 +341,11 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
     (value: RangeValue) => {
       const [inputMin, inputMax] = value;
       setDataMask({
-        extraFormData: getRangeExtraFormData(col, inputMin, inputMax),
+        extraFormData: getRangeExtraFormData(
+          col,
+          inputMin,
+          enableSingleExactValue ? inputMin : inputMax,
+        ),
         filterState: {
           value: enableSingleExactValue
             ? [inputMin, inputMin]
@@ -331,11 +356,32 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
         },
       });
     },
-    [setDataMask],
+    [setDataMask, col, enableSingleExactValue],
   );
 
   useEffect(() => {
-    if (row?.min === undefined && row?.max === undefined) {
+    if (nonNumericColumn) {
+      return;
+    }
+
+    if (manualBounds) {
+      // Invalid edits keep their draft in the inputs; external saved values
+      // and Clear All are validated without requiring a discovered domain.
+      const value: RangeValue =
+        filterState.validateStatus === 'error'
+          ? inputValue
+          : (filterState.value ?? [null, null]);
+      setInputValue(value);
+      const { isValid, errorMessage } = validateRange(
+        value,
+        undefined,
+        undefined,
+        enableEmptyFilter,
+        enableSingleValue,
+      );
+      setError(errorMessage);
+      if (isValid) updateDataMaskValue(value);
+      else updateDataMaskError(errorMessage);
       return;
     }
 
@@ -386,7 +432,13 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
       setInputValue(filterState.value);
       updateDataMaskValue(filterState.value);
     }
-  }, [JSON.stringify(filterState.value)]);
+  }, [
+    JSON.stringify(filterState.value),
+    manualBounds,
+    enableEmptyFilter,
+    enableSingleValue,
+    col,
+  ]);
 
   // Get just the filter behavior text without the range information (which is shown in the tooltip)
   const metadataText = useMemo(() => {
@@ -410,14 +462,19 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
 
   const handleChange = useCallback(
     (newValue: number | null, index: 0 | 1) => {
-      if (row?.min === undefined && row?.max === undefined) {
+      if (nonNumericColumn) {
         return;
       }
 
       // When using increment/decrement buttons on an empty input,
       // use the dataset min/max as the base value
       let adjustedValue = newValue;
-      if (newValue !== null && inputValue[index] === null) {
+      if (
+        min !== undefined &&
+        max !== undefined &&
+        newValue !== null &&
+        inputValue[index] === null
+      ) {
         if (keyPressed.current) {
           adjustedValue = newValue;
           keyPressed.current = false;
@@ -459,6 +516,8 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
     [
       min,
       max,
+      manualBounds,
+      nonNumericColumn,
       enableEmptyFilter,
       enableSingleValue,
       updateDataMaskError,
@@ -470,6 +529,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
   // Handler for slider change
   const handleSliderChange = useCallback(
     (value: number | number[]) => {
+      if (min === undefined || max === undefined) return;
       let newInputValue: RangeValue;
 
       if (enableSingleValue !== undefined) {
@@ -502,10 +562,13 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
   );
 
   const getMessageAndStatus = useCallback(() => {
-    const defaultMessage = t('Choose numbers between %(min)s and %(max)s', {
-      min,
-      max,
-    });
+    const defaultMessage =
+      manualBounds || min === undefined || max === undefined
+        ? t('Leave a bound empty for an unbounded range.')
+        : t('Choose numbers between %(min)s and %(max)s', {
+            min,
+            max,
+          });
 
     if (error) {
       return { message: error, status: 'error' as const };
@@ -516,7 +579,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
     }
 
     return { message: defaultMessage, status: 'help' as const };
-  }, [error, min, max, enableSingleValue, metadataText]);
+  }, [error, min, max, manualBounds, enableSingleValue, metadataText]);
 
   const MessageDisplay = useCallback(() => {
     const { message, status } = getMessageAndStatus();
@@ -542,7 +605,11 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
     );
   }, [getMessageAndStatus]);
 
+  const previousSingleValue = useRef(enableSingleValue);
   useEffect(() => {
+    if (manualBounds && previousSingleValue.current === enableSingleValue)
+      return;
+    previousSingleValue.current = enableSingleValue;
     if (enableSingleValue !== undefined) {
       switch (enableSingleValue) {
         case SingleValueType.Minimum:
@@ -572,6 +639,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
   }, [enableSingleValue]);
 
   const renderSlider = () => {
+    if (min === undefined || max === undefined) return null;
     if (enableSingleValue !== undefined) {
       return (
         <SliderWrapper>
@@ -605,6 +673,37 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
     );
   };
 
+  const renderInput = (
+    index: 0 | 1,
+    label: string,
+    bound: number | undefined,
+  ) => {
+    const input = (
+      <InputNumber
+        value={inputValue[index]}
+        onChange={val => handleChange(val, index)}
+        onKeyDown={handleKeyDown}
+        aria-label={manualBounds ? label : undefined}
+        placeholder={manualBounds || bound === undefined ? '' : `${bound}`}
+        style={{ width: '100%' }}
+        status={filterState.validateStatus}
+        data-test={
+          index === minIndex
+            ? 'range-filter-from-input'
+            : 'range-filter-to-input'
+        }
+      />
+    );
+    return manualBounds ? (
+      <label>
+        {label}
+        {input}
+      </label>
+    ) : (
+      input
+    );
+  };
+
   const renderInputs = () => (
     <Wrapper
       tabIndex={-1}
@@ -618,38 +717,24 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
       {/* Conditionally render based on enableSingleValue */}
       {(enableSingleValue === undefined ||
         enableSingleValue === SingleValueType.Minimum ||
-        enableSingleValue === SingleValueType.Exact) && (
-        <InputNumber
-          value={inputValue[minIndex]}
-          onChange={val => handleChange(val, minIndex)}
-          onKeyDown={handleKeyDown}
-          placeholder={`${min}`}
-          style={{ width: '100%' }}
-          status={filterState.validateStatus}
-          data-test="range-filter-from-input"
-        />
-      )}
+        enableSingleValue === SingleValueType.Exact) &&
+        renderInput(
+          minIndex,
+          enableSingleExactValue ? t('Value') : t('Minimum value'),
+          min,
+        )}
 
       {enableSingleValue === undefined && <StyledDivider>-</StyledDivider>}
 
       {(enableSingleValue === undefined ||
-        enableSingleValue === SingleValueType.Maximum) && (
-        <InputNumber
-          value={inputValue[maxIndex]}
-          onChange={val => handleChange(val, maxIndex)}
-          onKeyDown={handleKeyDown}
-          placeholder={`${max}`}
-          style={{ width: '100%' }}
-          data-test="range-filter-to-input"
-          status={filterState.validateStatus}
-        />
-      )}
+        enableSingleValue === SingleValueType.Maximum) &&
+        renderInput(maxIndex, t('Maximum value'), max)}
     </Wrapper>
   );
 
   return (
     <FilterPluginStyle height={height} width={width}>
-      {Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? (
+      {nonNumericColumn ? (
         <h4>{t('Chosen non-numeric column')}</h4>
       ) : (
         <FormItem aria-labelledby={`filter-name-${formData.nativeFilterId}`}>
diff --git a/superset-frontend/src/filters/components/Range/buildQuery.test.ts b/superset-frontend/src/filters/components/Range/buildQuery.test.ts
new file mode 100644
index 00000000000..83bd975646d
--- /dev/null
+++ b/superset-frontend/src/filters/components/Range/buildQuery.test.ts
@@ -0,0 +1,50 @@
+/**
+ * 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 { DatasourceType } from '@superset-ui/core';
+import buildQuery from './buildQuery';
+
+test('semantic ranges issue no bounds query, preserving source identity', () => {
+  const context = buildQuery({
+    datasource: '3__semantic_view',
+    viz_type: 'filter_range',
+    groupby: ['Orders.usersAge'],
+    semantic_selection_version: 'cube-member-id-v1',
+  });
+  expect(context.queries).toEqual([]);
+  expect(context.datasource).toEqual({
+    id: 3,
+    type: DatasourceType.SemanticView,
+  });
+  expect(context.form_data?.semantic_selection_version).toBe(
+    'cube-member-id-v1',
+  );
+});
+
+test('SQL dataset ranges retain their MIN/MAX discovery query', () => {
+  const context = buildQuery({
+    datasource: '3__table',
+    viz_type: 'filter_range',
+    groupby: ['age'],
+  });
+  expect(context.queries).toHaveLength(1);
+  expect(context.queries[0].metrics).toEqual([
+    expect.objectContaining({ aggregate: 'MIN', label: 'min' }),
+    expect.objectContaining({ aggregate: 'MAX', label: 'max' }),
+  ]);
+});
diff --git a/superset-frontend/src/filters/components/Range/buildQuery.ts b/superset-frontend/src/filters/components/Range/buildQuery.ts
index 1ba7aa9da7c..8fcfd1be05d 100644
--- a/superset-frontend/src/filters/components/Range/buildQuery.ts
+++ b/superset-frontend/src/filters/components/Range/buildQuery.ts
@@ -18,6 +18,7 @@
  */
 import { buildQueryContext, QueryFormData } from '@superset-ui/core';
 import { GenericDataType } from '@apache-superset/core/common';
+import isSemanticRange from './isSemanticRange';
 
 /**
  * The buildQuery function is used to create an instance of QueryContext that's
@@ -34,6 +35,9 @@ import { GenericDataType } from '@apache-superset/core/common';
  * if a viz needs multiple different result sets.
  */
 export default function buildQuery(formData: QueryFormData) {
+  if (isSemanticRange(formData)) {
+    return buildQueryContext(formData, () => []);
+  }
   const { groupby } = formData;
   const [column = ''] = groupby || [];
   // @ts-expect-error (need update interface Column )
diff --git a/superset-frontend/src/filters/components/Range/index.ts b/superset-frontend/src/filters/components/Range/index.ts
index 2733a35e9e1..a6aa3d9fd42 100644
--- a/superset-frontend/src/filters/components/Range/index.ts
+++ b/superset-frontend/src/filters/components/Range/index.ts
@@ -29,6 +29,8 @@ export default class RangeFilterPlugin extends ChartPlugin {
       name: t('Range filter'),
       description: t('Range filter plugin using AntD'),
       behaviors: [Behavior.InteractiveChart, Behavior.NativeFilter],
+      // Manual bounds remain usable without a query result.
+      enableNoResults: false,
       supportsCascadeDependencies: true,
       tags: [t('Experimental')],
       thumbnail,
diff --git a/superset-frontend/src/filters/components/Range/isSemanticRange.ts b/superset-frontend/src/filters/components/Range/isSemanticRange.ts
new file mode 100644
index 00000000000..6d0fb1dce08
--- /dev/null
+++ b/superset-frontend/src/filters/components/Range/isSemanticRange.ts
@@ -0,0 +1,32 @@
+/**
+ * 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 { DatasourceKey, DatasourceType } from '@superset-ui/core';
+
+/** Identify numeric filters whose source cannot provide aggregate bounds. */
+export default function isSemanticRange(formData: {
+  datasource?: string;
+  viz_type?: string;
+  vizType?: string;
+}): boolean {
+  return (
+    (formData.viz_type ?? formData.vizType) === 'filter_range' &&
+    new DatasourceKey(formData.datasource ?? '').type ===
+      DatasourceType.SemanticView
+  );
+}
diff --git a/superset-frontend/src/filters/components/Range/transformProps.ts b/superset-frontend/src/filters/components/Range/transformProps.ts
index cbca7a982a3..d09f8ce5015 100644
--- a/superset-frontend/src/filters/components/Range/transformProps.ts
+++ b/superset-frontend/src/filters/components/Range/transformProps.ts
@@ -39,7 +39,7 @@ export default function transformProps(chartProps: ChartProps) {
     unsetHoveredFilter = noOp,
     setFilterActive = noOp,
   } = hooks;
-  const { data } = queriesData[0];
+  const data = queriesData[0]?.data ?? [];
 
   return {
     data,
diff --git a/superset-frontend/src/utils/semanticViewStructure.test.ts b/superset-frontend/src/utils/semanticViewStructure.test.ts
index 356bc606446..fa63550ce63 100644
--- a/superset-frontend/src/utils/semanticViewStructure.test.ts
+++ b/superset-frontend/src/utils/semanticViewStructure.test.ts
@@ -17,6 +17,7 @@
  * under the License.
  */
 import fetchMock from 'fetch-mock';
+import { GenericDataType } from '@apache-superset/core/common';
 import { supersetGetCache } from 'src/utils/cachedSupersetGet';
 import {
   fetchSemanticViewStructure,
@@ -92,3 +93,52 @@ test.each([undefined, null])(
     expect(structure.semantic_selection_version).toBeUndefined();
   },
 );
+
+test.each([
+  'bigint',
+  'integer',
+  'smallint',
+  'tinyint',
+  'numeric',
+  'numeric(10,2)',
+  'real',
+  'BIGINT',
+  'INTEGER',
+])('preserves SQL numeric dimension %s in the shared column metadata', type => {
+  expect(semanticViewDimensionsToColumns([{ name: 'amount', type }])).toEqual([
+    expect.objectContaining({
+      column_name: 'amount',
+      type_generic: GenericDataType.Numeric,
+    }),
+  ]);
+});
+
+test.each([
+  'int8',
+  'int16',
+  'int32',
+  'int64',
+  'uint8',
+  'uint16',
+  'uint32',
+  'uint64',
+  'halffloat',
+  'float',
+  'double',
+  'decimal32(7, 2)',
+  'decimal64(7, 2)',
+  'decimal128(10, 2)',
+  'decimal256(40, 2)',
+])(
+  'preserves Arrow numeric dimension %s in the shared column metadata',
+  type => {
+    expect(semanticViewDimensionsToColumns([{ name: 'amount', type }])).toEqual(
+      [
+        expect.objectContaining({
+          column_name: 'amount',
+          type_generic: GenericDataType.Numeric,
+        }),
+      ],
+    );
+  },
+);
diff --git a/superset-frontend/src/utils/semanticViewStructure.ts b/superset-frontend/src/utils/semanticViewStructure.ts
index 3b57d68d1ff..907704fa2b7 100644
--- a/superset-frontend/src/utils/semanticViewStructure.ts
+++ b/superset-frontend/src/utils/semanticViewStructure.ts
@@ -53,7 +53,7 @@ export const mapSemanticTypeToGenericDataType = (
   }
 
   if (
-    /(\b(u?int\d*)\b|\bfloat\d*\b|\bdouble\b|\bdecimal\d*\b|\bnumber\b)/.test(
+    /(\b(u?int\d*|integer|bigint|smallint|tinyint|numeric|real|halffloat)\b|\bfloat\d*\b|\bdouble\b|\bdecimal\d*\b|\bnumber\b)/.test(
       normalized,
     )
   ) {
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index dfe1d3fc22d..d004f070dee 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -8199,6 +8199,9 @@ msgstr ""
 msgid "Least recently modified"
 msgstr ""
 
+msgid "Leave a bound empty for an unbounded range."
+msgstr ""
+
 msgid "Leave blank to keep the current password"
 msgstr ""