Compare commits

...

10 Commits

Author SHA1 Message Date
Evan Rusackas
9771ad601a A couple more tweaks 2025-07-21 09:40:39 -07:00
Evan Rusackas
6e5360b7ab chore(ts): removing unused variables 2025-07-18 15:15:26 -07:00
Maxime Beauchemin
2db8f809ba fix: proper handling of boolean filters with snowflake (#34199)
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
2025-07-18 12:13:52 -07:00
Erkka Tahvanainen
5912fad745 fix(dashboard): Fix subitem selection on dashboard download menu (#33933)
Co-authored-by: Erkka Tahvanainen <erkka.tahvanainen@confidently.fi>
2025-07-18 11:16:19 -07:00
mdusmanalvi
dc41c45bec feat(pivot-table-chart): Download as pivoted excel (#33569)
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
2025-07-18 11:12:14 -07:00
Joe Li
88ee90c579 chore: Updates files related to 4.1.3 release (#34217) 2025-07-18 09:59:06 -07:00
Gabriel Torres Ruiz
bbb2279644 fix(theming): Superset theme configurations correctly applying to charts (#34218) 2025-07-18 16:25:48 +03:00
Maxime Beauchemin
1958df6b83 fix: dataset endpoint /rowlevelsecurity/related/tables doesn't apply filters as expected (#34192) 2025-07-17 15:51:03 -07:00
eriks47
58bd3bfcf0 fix(chart): update geographical info for latvia (#33450) 2025-07-17 13:32:34 -07:00
Denis Krivenko
d6eb6e08d0 style(helm): Minor reformatting of helm chart templates (#33736) 2025-07-17 11:33:09 -07:00
65 changed files with 806 additions and 401 deletions

View File

@@ -42,7 +42,7 @@ body:
options:
- master / latest-dev
- "5.0.0"
- "4.1.2"
- "4.1.3"
validations:
required: true
- type: dropdown

58
CHANGELOG/4.1.3.md Normal file
View File

@@ -0,0 +1,58 @@
<!--
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.
-->
## Change Log
### 4.1.3 (Thu May 29 02:31:07 2025 -0500)
**Database Migrations**
**Features**
**Fixes**
- [#33522](https://github.com/apache/superset/pull/33522) fix(Sqllab): Autocomplete got stuck in UI when open it too fast (@rebenitez1802)
- [#33425](https://github.com/apache/superset/pull/33425) fix(table-chart): time shift is not working (@justinpark)
- [#32414](https://github.com/apache/superset/pull/32414) fix(api): Added uuid to list api calls (@withnale)
- [#33354](https://github.com/apache/superset/pull/33354) fix: loading examples from raw.githubusercontent.com fails with 429 errors (@mistercrunch)
- [#32382](https://github.com/apache/superset/pull/32382) fix(pinot): revert join and subquery flags (@yuribogomolov)
- [#32473](https://github.com/apache/superset/pull/32473) fix(plugin-chart-echarts): remove erroneous upper bound value (@villebro)
- [#33048](https://github.com/apache/superset/pull/33048) fix: improve error type on parse error (@justinpark)
- [#32968](https://github.com/apache/superset/pull/32968) fix(pivot-table): Revert "fix(Pivot Table): Fix column width to respect currency config (#31414)" (@justinpark)
- [#32795](https://github.com/apache/superset/pull/32795) fix(log): store navigation path to get correct logging path (@justinpark)
- [#33216](https://github.com/apache/superset/pull/33216) fix: Downgrade to marshmallow<4 (@amotl)
- [#32866](https://github.com/apache/superset/pull/32866) fix: make packages PEP 625 compliant (@sadpandajoe)
- [#32035](https://github.com/apache/superset/pull/32035) fix(fe/dashboard-list): display modifier info for `Last modified` data (@hainenber)
- [#32708](https://github.com/apache/superset/pull/32708) fix(logging): missing path in event data (@justinpark)
- [#32699](https://github.com/apache/superset/pull/32699) fix: Signature of Celery pruner jobs (@michael-s-molina)
- [#32681](https://github.com/apache/superset/pull/32681) fix(log): Update recent_activity by event name (@justinpark)
- [#32608](https://github.com/apache/superset/pull/32608) fix(welcome): perf on distinct recent activities (@justinpark)
- [#32572](https://github.com/apache/superset/pull/32572) fix: Log table retention policy (@michael-s-molina)
- [#32406](https://github.com/apache/superset/pull/32406) fix(model/helper): represent RLS filter clause in proper textual SQL string (@hainenber)
- [#32240](https://github.com/apache/superset/pull/32240) fix: upgrade to 3.11.11-slim-bookworm to address critical vulnerabilities (@gpchandran)
- [#30858](https://github.com/apache/superset/pull/30858) fix(chart data): removing query from /chart/data payload when accessing as guest user (@fisjac)
**Others**
- [#33612](https://github.com/apache/superset/pull/33612) chore: update Dockerfile - Upgrade to 3.11.12 (@gpchandran)
- [#33435](https://github.com/apache/superset/pull/33435) docs: CVEs fixed on 4.1.2 (@sha174n)
- [#33339](https://github.com/apache/superset/pull/33339) chore(🦾): bump python h11 0.14.0 -> 0.16.0 (@github-actions[bot])
- [#32745](https://github.com/apache/superset/pull/32745) chore(🦾): bump python sqlglot 26.1.3 -> 26.11.1 (@github-actions[bot])
- [#32782](https://github.com/apache/superset/pull/32782) chore: Revert "chore: bump base image in Dockerfile with `ARG PY_VER=3.11.11-slim-bookworm`" (@sadpandajoe)
- [#32780](https://github.com/apache/superset/pull/32780) chore: bump base image in Dockerfile with `ARG PY_VER=3.11.11-slim-bookworm` (@gpchandran)

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.14.2
version: 0.14.3
dependencies:
- name: postgresql
version: 13.4.4

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.14.2](https://img.shields.io/badge/Version-0.14.2-informational?style=flat-square)
![Version: 0.14.3](https://img.shields.io/badge/Version-0.14.3-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -28,9 +28,9 @@ metadata:
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- if .Values.init.jobAnnotations }}
annotations: {{- toYaml .Values.init.jobAnnotations | nindent 4 }}
{{- end }}
@@ -44,10 +44,10 @@ spec:
{{- if or .Values.extraLabels .Values.init.podLabels }}
labels:
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- toYaml .Values.extraLabels | nindent 8 }}
{{- end }}
{{- if .Values.init.podLabels }}
{{- toYaml .Values.init.podLabels | nindent 8 }}
{{- toYaml .Values.init.podLabels | nindent 8 }}
{{- end }}
{{- end }}
spec:

View File

@@ -20,7 +20,7 @@
{{- with .Values.supersetCeleryBeat.podDisruptionBudget }}
{{- if .enabled -}}
{{- if and .minAvailable .maxUnavailable }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- end}}
apiVersion: policy/v1
kind: PodDisruptionBudget
@@ -35,12 +35,12 @@ metadata:
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
spec:
{{- if .minAvailable }}
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}
{{- end }}
{{- if .maxUnavailable }}
{{- end }}
{{- if .maxUnavailable }}
maxUnavailable: {{ .maxUnavailable }}
{{- end }}
{{- end }}
selector:
matchLabels:
{{- include "supersetCeleryBeat.selectorLabels" $ | nindent 6 }}

View File

@@ -20,7 +20,7 @@
{{- with .Values.supersetCeleryFlower.podDisruptionBudget }}
{{- if .enabled -}}
{{- if and .minAvailable .maxUnavailable }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- end}}
apiVersion: policy/v1
kind: PodDisruptionBudget
@@ -35,12 +35,12 @@ metadata:
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
spec:
{{- if .minAvailable }}
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}
{{- end }}
{{- if .maxUnavailable }}
{{- end }}
{{- if .maxUnavailable }}
maxUnavailable: {{ .maxUnavailable }}
{{- end }}
{{- end }}
selector:
matchLabels:
{{- include "supersetCeleryFlower.selectorLabels" $ | nindent 6 }}

View File

@@ -20,7 +20,7 @@
{{- with .Values.supersetWorker.podDisruptionBudget }}
{{- if .enabled -}}
{{- if and .minAvailable .maxUnavailable }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- end}}
apiVersion: policy/v1
kind: PodDisruptionBudget
@@ -35,12 +35,12 @@ metadata:
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
spec:
{{- if .minAvailable }}
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}
{{- end }}
{{- if .maxUnavailable }}
{{- end }}
{{- if .maxUnavailable }}
maxUnavailable: {{ .maxUnavailable }}
{{- end }}
{{- end }}
selector:
matchLabels:
{{- include "supersetWorker.selectorLabels" $ | nindent 6 }}

View File

@@ -20,7 +20,7 @@
{{- with .Values.supersetWebsockets.podDisruptionBudget }}
{{- if .enabled -}}
{{- if and .minAvailable .maxUnavailable }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- end}}
apiVersion: policy/v1
kind: PodDisruptionBudget
@@ -35,12 +35,12 @@ metadata:
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
spec:
{{- if .minAvailable }}
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}
{{- end }}
{{- if .maxUnavailable }}
{{- end }}
{{- if .maxUnavailable }}
maxUnavailable: {{ .maxUnavailable }}
{{- end }}
{{- end }}
selector:
matchLabels:
{{- include "supersetWebsockets.selectorLabels" $ | nindent 6 }}

View File

@@ -20,7 +20,7 @@
{{- with .Values.supersetNode.podDisruptionBudget }}
{{- if .enabled -}}
{{- if and .minAvailable .maxUnavailable }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- fail "Only one of minAvailable or maxUnavailable should be set" }}
{{- end}}
apiVersion: policy/v1
kind: PodDisruptionBudget
@@ -35,12 +35,12 @@ metadata:
{{- toYaml $.Values.extraLabels | nindent 4 }}
{{- end }}
spec:
{{- if .minAvailable }}
{{- if .minAvailable }}
minAvailable: {{ .minAvailable }}
{{- end }}
{{- if .maxUnavailable }}
{{- end }}
{{- if .maxUnavailable }}
maxUnavailable: {{ .maxUnavailable }}
{{- end }}
{{- end }}
selector:
matchLabels:
{{- include "supersetNode.selectorLabels" $ | nindent 6 }}

View File

@@ -163,7 +163,7 @@ module.exports = {
'@typescript-eslint/no-non-null-assertion': 0, // disabled temporarily
'@typescript-eslint/explicit-function-return-type': 0,
'@typescript-eslint/explicit-module-boundary-types': 0, // re-enable up for discussion
'@typescript-eslint/no-unused-vars': 'warn', // downgrade to Warning severity for Jest v30 upgrade
'@typescript-eslint/no-unused-vars': 'error',
camelcase: 0,
'class-methods-use-this': 0,
'func-names': 0,

View File

@@ -135,6 +135,7 @@
"use-event-callback": "^0.1.0",
"use-immer": "^0.9.0",
"use-query-params": "^1.1.9",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "^17.7.2"
},
"devDependencies": {
@@ -56626,6 +56627,18 @@
}
}
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",

View File

@@ -104,12 +104,12 @@
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
"@superset-ui/legacy-preset-chart-deckgl": "file:./plugins/legacy-preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
"@types/d3-format": "^3.0.1",
@@ -203,6 +203,7 @@
"use-event-callback": "^0.1.0",
"use-immer": "^0.9.0",
"use-query-params": "^1.1.9",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "^17.7.2"
},
"devDependencies": {

View File

@@ -33,7 +33,6 @@ export function Label(props: LabelProps) {
onClick,
children,
icon,
id,
...rest
} = props;

View File

@@ -27,8 +27,6 @@ const DEFAULT_TIMEZONE = {
value: 'Africa/Abidjan', // timezones are deduped by the first alphabetical value
};
const MIN_SELECT_WIDTH = '400px';
const offsetsToName = {
'-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
'-360-300': ['Central Standard Time', 'Central Daylight Time'],
@@ -48,13 +46,11 @@ const offsetsToName = {
export type TimezoneSelectorProps = {
onTimezoneChange: (value: string) => void;
timezone?: string | null;
minWidth?: string;
};
export default function TimezoneSelector({
onTimezoneChange,
timezone,
minWidth = MIN_SELECT_WIDTH, // smallest size for current values
...rest
}: TimezoneSelectorProps) {
const { TIMEZONE_OPTIONS, TIMEZONE_OPTIONS_SORT_COMPARATOR, validTimezone } =
@@ -156,6 +152,7 @@ export default function TimezoneSelector({
value={validTimezone}
options={TIMEZONE_OPTIONS}
sortComparator={TIMEZONE_OPTIONS_SORT_COMPARATOR}
style={{ minWidth }}
{...rest}
/>
);

File diff suppressed because one or more lines are too long

View File

@@ -265,11 +265,7 @@ const DeckMulti = (props: DeckMultiProps) => {
);
const loadLayers = useCallback(
(
formData: QueryFormData,
payload: JsonObject,
viewport?: Viewport,
): void => {
(formData: QueryFormData, payload: JsonObject): void => {
setViewport(getAdjustedViewport());
setSubSlicesLayers({});

View File

@@ -138,7 +138,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
defaultAlignPN: alignPositiveNegative,
showCellBars,
colorPositiveNegative,
totals,
columnColorFormatters,
allowRearrangeColumns,
basicColorFormatters,

View File

@@ -19,7 +19,7 @@
*/
import { ColDef } from 'ag-grid-community';
import { useCallback, useMemo } from 'react';
import { DataRecord, GenericDataType } from '@superset-ui/core';
import { GenericDataType } from '@superset-ui/core';
import { ColorFormatters } from '@superset-ui/chart-controls';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import {
@@ -49,7 +49,6 @@ type UseColDefsProps = {
defaultAlignPN: boolean;
showCellBars: boolean;
colorPositiveNegative: boolean;
totals: DataRecord | undefined;
columnColorFormatters: ColorFormatters;
allowRearrangeColumns?: boolean;
basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[];
@@ -136,7 +135,6 @@ export const useColDefs = ({
defaultAlignPN,
showCellBars,
colorPositiveNegative,
totals,
columnColorFormatters,
allowRearrangeColumns,
basicColorFormatters,

View File

@@ -336,13 +336,8 @@ const config: ControlPanelConfig = {
description: t(
'Stack in groups, where each group corresponds to a dimension',
),
shouldMapStateToProps: (
prevState,
state,
controlState,
chartState,
) => true,
mapStateToProps: (state, controlState, chartState) => {
shouldMapStateToProps: () => true,
mapStateToProps: state => {
const value: JsonArray = state.controls.groupby
.value as JsonArray;
const valueAsStringArr: string[][] = value.map(v => {

View File

@@ -42,7 +42,6 @@ import {
LocalStorageKeys,
setItem,
} from 'src/utils/localStorageHelpers';
import { noop } from 'lodash';
import TableElement from '../TableElement';
export interface SqlEditorLeftBarProps {
@@ -86,7 +85,7 @@ const SqlEditorLeftBar = ({
'schema',
]);
const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false);
const [, setEmptyResultsWithSearch] = useState(false);
const [userSelectedDb, setUserSelected] = useState<DatabaseObject | null>(
null,
);
@@ -99,8 +98,6 @@ const SqlEditorLeftBar = ({
[allSelectedTables, dbId, schema],
);
noop(_emptyResultsWithSearch); // This is to avoid unused variable warning, can be removed if not needed
useEffect(() => {
const bool = new URLSearchParams(window.location.search).get('db');
const userSelected = getItem(

View File

@@ -23,7 +23,6 @@ import { ErrorAlert } from './ErrorAlert';
export function FrontendNetworkErrorMessage({
error,
subtitle,
compact,
}: ErrorMessageComponentProps) {
const { level, message } = error;

View File

@@ -59,6 +59,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & {
formData: object;
width: number;
height: number;
exportPivotExcel?: (arg0: string) => void;
};
const annotationsLoading = t('Annotation layers are still loading.');
@@ -167,6 +168,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
formData,
width,
height,
exportPivotExcel = () => ({}),
},
ref,
) => {
@@ -344,6 +346,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
formData={formData}
exploreUrl={exploreUrl}
crossFiltersEnabled={isCrossFiltersEnabled}
exportPivotExcel={exportPivotExcel}
/>
)}
</>

View File

@@ -33,6 +33,7 @@ const createProps = (viz_type = VizType.Sunburst) =>
exportFullCSV: jest.fn(),
exportXLSX: jest.fn(),
exportFullXLSX: jest.fn(),
exportPivotExcel: jest.fn(),
forceRefresh: jest.fn(),
handleToggleFullSize: jest.fn(),
toggleExpandSlice: jest.fn(),
@@ -254,6 +255,20 @@ test('Should not show export full Excel if report is not table', async () => {
expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument();
});
test('Should export to pivoted Excel if report is pivot table', async () => {
const props = createProps(VizType.PivotTable);
renderWrapper(props);
openMenu();
expect(props.exportPivotExcel).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export to Pivoted Excel'));
expect(props.exportPivotExcel).toHaveBeenCalledTimes(1);
expect(props.exportPivotExcel).toHaveBeenCalledWith(
'.pvtTable',
props.slice.slice_name,
);
});
test('Should "Show chart description"', () => {
const props = createProps();
renderWrapper(props);

View File

@@ -128,6 +128,7 @@ export interface SliceHeaderControlsProps {
exportXLSX?: (sliceId: number) => void;
exportFullXLSX?: (sliceId: number) => void;
handleToggleFullSize: () => void;
exportPivotExcel?: (tableSelector: string, sliceName: string) => void;
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
@@ -255,6 +256,10 @@ const SliceHeaderControls = (
});
break;
}
case MenuKeys.ExportPivotXlsx: {
props.exportPivotExcel?.('.pvtTable', props.slice.slice_name);
break;
}
case MenuKeys.CrossFilterScoping: {
openScopingModal();
break;
@@ -468,6 +473,15 @@ const SliceHeaderControls = (
{t('Export to Excel')}
</Menu.Item>
{isPivotTable && (
<Menu.Item
key={MenuKeys.ExportPivotXlsx}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to Pivoted Excel')}
</Menu.Item>
)}
{isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
props.supersetCanCSV &&
isTable && (

View File

@@ -37,6 +37,7 @@ import {
import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants';
import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
import SliceHeader from '../SliceHeader';
import MissingChart from '../MissingChart';
@@ -471,6 +472,7 @@ const Chart = props => {
formData={formData}
width={width}
height={getHeaderHeight()}
exportPivotExcel={exportPivotExcel}
/>
{/*

View File

@@ -27,6 +27,7 @@ export default function DownloadAsImage({
text,
logEvent,
dashboardTitle,
...props
}: {
text: string;
dashboardTitle: string;
@@ -50,6 +51,7 @@ export default function DownloadAsImage({
onClick={e => {
onDownloadImage(e.domEvent);
}}
{...props}
>
{text}
</Menu.Item>

View File

@@ -27,6 +27,7 @@ export default function DownloadAsPdf({
text,
logEvent,
dashboardTitle,
...props
}: {
text: string;
dashboardTitle: string;
@@ -50,6 +51,7 @@ export default function DownloadAsPdf({
onClick={e => {
onDownloadPdf(e.domEvent);
}}
{...props}
>
{text}
</Menu.Item>

View File

@@ -35,7 +35,7 @@ interface TitleContainerProps {
const FILTER_TYPE = 'FILTER';
const Container = styled.div<TitleContainerProps>`
${({ isDragging, theme }) => `
${({ isDragging }) => `
opacity: ${isDragging ? 0.3 : 1};
cursor: ${isDragging ? 'grabbing' : 'pointer'};
width: 100%;

View File

@@ -289,4 +289,5 @@ export enum MenuKeys {
ToggleFullscreen = 'toggle_fullscreen',
ManageEmbedded = 'manage_embedded',
ManageEmailReports = 'manage_email_reports',
ExportPivotXlsx = 'export_pivot_xlsx',
}

View File

@@ -138,12 +138,16 @@ export default function DataSourcePanel({
width,
}: Props) {
const [dropzones] = useContext(DropzoneContext);
const { columns: _columns, metrics, folders: _folders } = datasource;
const {
columns: datasourceColumns,
metrics,
folders: datasourceFolders,
} = datasource;
const allowedColumns = useMemo(() => {
const validators = Object.values(dropzones);
if (!Array.isArray(_columns)) return [];
return _columns.filter(column =>
if (!Array.isArray(datasourceColumns)) return [];
return datasourceColumns.filter(column =>
validators.some(validator =>
validator({
value: column as DndItemValue,
@@ -151,7 +155,7 @@ export default function DataSourcePanel({
}),
),
);
}, [dropzones, _columns]);
}, [dropzones, datasourceColumns]);
const allowedMetrics = useMemo(() => {
const validators = Object.values(dropzones);
@@ -234,11 +238,11 @@ export default function DataSourcePanel({
transformDatasourceWithFolders(
filteredMetrics,
sortedColumns,
_folders,
datasourceFolders,
allowedMetrics,
allowedColumns,
),
[_folders, filteredMetrics, sortedColumns],
[datasourceFolders, filteredMetrics, sortedColumns],
);
const showInfoboxCheck = () => {

View File

@@ -47,6 +47,7 @@ export const ExploreAlert = forwardRef(
ref: RefObject<HTMLDivElement>,
) => (
<ErrorAlert
ref={ref}
errorType={title}
message={bodyText}
type={type}

View File

@@ -65,18 +65,19 @@ export default function ColumnConfigControl<T extends ColumnConfig>({
height,
...props
}: ColumnConfigControlProps<T>) {
const { colnames: _colnames, coltypes: _coltypes } = columnsPropsObject || {};
const { colnames: sourceColnames, coltypes: sourceColtypes } =
columnsPropsObject || {};
let colnames: string[] = [];
let coltypes: GenericDataType[] = [];
if (appliedColumnNames.length === 0) {
colnames = _colnames || [];
coltypes = _coltypes || [];
colnames = sourceColnames || [];
coltypes = sourceColtypes || [];
} else {
const appliedCol = new Set(appliedColumnNames);
_colnames?.forEach((col, idx) => {
sourceColnames?.forEach((col, idx) => {
if (appliedCol.has(col)) {
colnames.push(col);
coltypes.push(_coltypes?.[idx] as GenericDataType);
coltypes.push(sourceColtypes?.[idx] as GenericDataType);
}
});
}

View File

@@ -43,6 +43,7 @@ import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED,
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
} from 'src/logger/LogUtils';
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
import ViewQueryModal from '../controls/ViewQueryModal';
import EmbedCodeContent from '../EmbedCodeContent';
import DashboardsSubMenu from './DashboardsSubMenu';
@@ -67,6 +68,7 @@ const MENU_KEYS = {
DELETE_REPORT: 'delete_report',
VIEW_QUERY: 'view_query',
RUN_IN_SQL_LAB: 'run_in_sql_lab',
EXPORT_TO_PIVOT_XLSX: 'export_to_pivot_xlsx',
};
const VIZ_TYPES_PIVOTABLE = [VizType.PivotTable];
@@ -248,6 +250,16 @@ export const useExploreAdditionalActionsMenu = (
}),
);
break;
case MENU_KEYS.EXPORT_TO_PIVOT_XLSX:
exportPivotExcel('.pvtTable', slice?.slice_name ?? t('pivoted_xlsx'));
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
downloadAsImage(
'.panel-body .chart-container',
@@ -365,6 +377,13 @@ export const useExploreAdditionalActionsMenu = (
>
{t('Export to Excel')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_PIVOT_XLSX}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to Pivoted Excel')}
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu title={t('Share')} key={MENU_KEYS.SHARE_SUBMENU}>
<Menu.Item key={MENU_KEYS.COPY_PERMALINK}>

View File

@@ -307,7 +307,7 @@ export const NotificationMethod: FunctionComponent<NotificationMethodProps> = ({
}
}
})
.catch(e => {
.catch(() => {
// Fallback to slack v1 if slack v2 is not compatible
setUseSlackV1(true);
})

View File

@@ -58,7 +58,6 @@ interface AllEntitiesTableProps {
}
export default function AllEntitiesTable({
search = '',
setShowTagModal,
objects,
canEditTag,

View File

@@ -187,7 +187,7 @@ const AnnotationModal: FunctionComponent<AnnotationModalProps> = ({
setCurrentAnnotation(data);
};
const onDateChange = (dates: any, dateString: Array<string>) => {
const onDateChange = (dates: any) => {
if (!dates?.[0] || !dates?.[1]) {
const data = {
...currentAnnotation,

View File

@@ -329,7 +329,9 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
const loadDatabaseOptions = useMemo(
() =>
(input = '', page: number, pageSize: number) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(input: string, page: number, pageSize: number) => {
// TODO: Implement search functionality using input parameter
const query = rison.encode_uri({
filters: [
{
@@ -357,21 +359,20 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
);
const loadSchemaOptions = useMemo(
() =>
(input = '', page: number, pageSize: number) => {
if (!currentDatabaseId) {
return Promise.resolve({ data: [], totalCount: 0 });
}
return SupersetClient.get({
endpoint: `/api/v1/database/${currentDatabaseId}/schemas/?q=(upload_allowed:!t)`,
}).then(response => {
const list = response.json.result.map((item: string) => ({
value: item,
label: item,
}));
return { data: list, totalCount: response.json.count };
});
},
() => () => {
if (!currentDatabaseId) {
return Promise.resolve({ data: [], totalCount: 0 });
}
return SupersetClient.get({
endpoint: `/api/v1/database/${currentDatabaseId}/schemas/?q=(upload_allowed:!t)`,
}).then(response => {
const list = response.json.result.map((item: string) => ({
value: item,
label: item,
}));
return { data: list, totalCount: response.json.count };
});
},
[currentDatabaseId],
);
@@ -546,7 +547,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
}
}, [show]);
const validateUpload = (_: any, value: string) => {
const validateUpload = () => {
if (fileList.length === 0) {
return Promise.reject(t('Uploading a file is required'));
}
@@ -561,7 +562,7 @@ const UploadDataModal: FunctionComponent<UploadDataModalProps> = ({
return Promise.resolve();
};
const validateDatabase = (_: any, value: string) => {
const validateDatabase = () => {
if (!currentDatabaseId) {
return Promise.reject(t('Selecting a database is required'));
}

View File

@@ -83,7 +83,7 @@ const BulkTagModal: FC<BulkTagModalProps> = ({
}
addSuccessToast(t('Tagged %s %ss', tagged.length, resourceName));
})
.catch(err => {
.catch(() => {
addDangerToast(t('Failed to tag items'));
});

View File

@@ -126,7 +126,7 @@ const TagModal: FC<TagModalProps> = ({
setChartsToTag(resourceMap[TaggableResources.Chart]);
setSavedQueriesToTag(resourceMap[TaggableResources.SavedQuery]);
},
(error: Response) => {
() => {
addDangerToast('Error Fetching Tagged Objects');
},
);

View File

@@ -48,6 +48,8 @@ function UserInfoModal({
const handleFormSubmit = async (values: FormValues) => {
try {
const { confirm_password, ...payload } = values;
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-expressions
confirm_password; // Destructured to exclude from payload
await SupersetClient.put({
endpoint: `/api/v1/me/`,
jsonPayload: { ...payload },

View File

@@ -22,6 +22,8 @@ import { FormValues } from './types';
export const createUser = async (values: FormValues) => {
const { confirmPassword, ...payload } = values;
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-expressions
confirmPassword; // Destructured to exclude from payload
if (payload.active == null) {
payload.active = false;
}

View File

@@ -154,7 +154,7 @@ function AllEntities() {
setObjects(objects);
setLoading(false);
},
(error: Response) => {
() => {
addDangerToast('Error Fetching Tagged Objects');
setLoading(false);
},
@@ -168,7 +168,7 @@ function AllEntities() {
setTag(tag);
setLoading(false);
},
(error: Response) => {
() => {
addDangerToast(t('Error Fetching Tagged Objects'));
setLoading(false);
},

View File

@@ -190,23 +190,21 @@ export default function ExplorePage() {
})()
: Promise.reject()
)
.then(
({ result: { id, url, owners, form_data: _, ...data } }) => {
const slice = {
...data,
datasource: err.extra?.datasource_name,
slice_id: id,
slice_url: url,
owners: owners?.map(({ id }) => id),
};
dispatch(
hydrateExplore({
...exploreData,
slice,
}),
);
},
)
.then(({ result: { id, url, owners, ...data } }) => {
const slice = {
...data,
datasource: err.extra?.datasource_name,
slice_id: id,
slice_url: url,
owners: owners?.map(({ id }) => id),
};
dispatch(
hydrateExplore({
...exploreData,
slice,
}),
);
})
.catch(() => {
dispatch(hydrateExplore(exploreData));
});

View File

@@ -285,11 +285,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const columns = useMemo(
() => [
{
Cell: ({
row: {
original: { kind },
},
}: any) => null,
Cell: () => null,
accessor: 'kind_icon',
disableSortBy: true,
size: 'xs',

View File

@@ -147,7 +147,7 @@ function GroupsList({ user }: GroupsListProps) {
.then(() => {
deletedGroupsNames.push(group.name);
})
.catch(err => {
.catch(() => {
addDangerToast(t('Error deleting %s', group.name));
}),
),

View File

@@ -168,7 +168,7 @@ function UsersList({ user }: UsersListProps) {
.then(() => {
deletedUserNames.push(user.username);
})
.catch(err => {
.catch(() => {
addDangerToast(t('Error deleting %s', user.username));
}),
),

View File

@@ -101,13 +101,14 @@ export class ThemeController {
const {
storage = new LocalStorageAdapter(),
modeStorageKey = STORAGE_KEYS.THEME_MODE,
themeObject: fallbackThemeObject = supersetThemeObject,
themeObject = supersetThemeObject,
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
onChange = null,
} = options;
this.storage = storage;
this.modeStorageKey = modeStorageKey;
this.themeObject = themeObject;
// Initialize bootstrap data and themes
const {
@@ -139,16 +140,14 @@ export class ThemeController {
// Initialize theme and mode
this.currentMode = this.determineInitialMode();
const { theme, themeObject } =
this.createInitialThemeObject(fallbackThemeObject);
this.themeObject = themeObject;
const initialTheme =
this.getThemeForMode(this.currentMode) || this.defaultTheme;
// Setup change callback
if (onChange) this.onChangeCallbacks.add(onChange);
// Apply initial theme and persist mode
this.applyTheme(theme);
this.applyTheme(initialTheme);
this.persistMode();
}
@@ -158,7 +157,12 @@ export class ThemeController {
* Cleans up listeners and references. Should be called when the controller is no longer needed.
*/
public destroy(): void {
this.mediaQuery.removeEventListener('change', this.handleSystemThemeChange);
if (this.mediaQuery)
this.mediaQuery.removeEventListener(
'change',
this.handleSystemThemeChange,
);
this.onChangeCallbacks.clear();
}
@@ -329,8 +333,12 @@ export class ThemeController {
* Initializes media query listeners if OS preference is allowed
*/
private initializeMediaQueryListener(): void {
this.mediaQuery = window.matchMedia(MEDIA_QUERY_DARK_SCHEME);
this.mediaQuery.addEventListener('change', this.handleSystemThemeChange);
try {
this.mediaQuery = window.matchMedia(MEDIA_QUERY_DARK_SCHEME);
this.mediaQuery.addEventListener('change', this.handleSystemThemeChange);
} catch (error) {
console.warn('Failed to initialize media query listener:', error);
}
}
/**
@@ -347,9 +355,9 @@ export class ThemeController {
settings: themeSettings,
} = theme;
const hasValidDefault: boolean = this.hasKeys(defaultTheme);
const hasValidDark: boolean = this.hasKeys(darkTheme);
const hasValidSettings: boolean = this.hasKeys(themeSettings);
const hasValidDefault: boolean = this.isNonEmptyObject(defaultTheme);
const hasValidDark: boolean = this.isNonEmptyObject(darkTheme);
const hasValidSettings: boolean = this.isNonEmptyObject(themeSettings);
return {
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
@@ -360,9 +368,11 @@ export class ThemeController {
}
/**
* Checks if an object has keys (not empty).
* Checks if an object is non-empty (has at least one property).
*/
private hasKeys(obj: Record<string, any> | undefined | null): boolean {
private isNonEmptyObject(
obj: Record<string, any> | undefined | null,
): boolean {
return Boolean(
obj && typeof obj === 'object' && Object.keys(obj).length > 0,
);
@@ -417,28 +427,7 @@ export class ThemeController {
? this.darkTheme || this.defaultTheme
: this.defaultTheme;
return normalizeThemeConfig(selectedTheme);
}
/**
* Creates the initial theme object.
* This sets the theme based on the current mode and ensures it has the correct algorithm.
* @param defaultThemeObject - The fallback theme object to use if no theme is set
* @returns An object containing the theme and the themeObject
*/
private createInitialThemeObject(defaultThemeObject: Theme): {
theme: AnyThemeConfig;
themeObject: Theme;
} {
let theme: AnyThemeConfig | null = this.getThemeForMode(this.currentMode);
theme = theme || (defaultThemeObject.theme as AnyThemeConfig);
const normalizedTheme = this.normalizeTheme(theme);
return {
theme: normalizedTheme,
themeObject: Theme.fromConfig(normalizedTheme),
};
return selectedTheme;
}
/**

View File

@@ -230,13 +230,13 @@ describe('ThemeController', () => {
expect(controller.getTheme()).toBe(mockThemeObject);
});
it('should use BootsrapData themes when available', () => {
it('should use BootstrapData themes when available', () => {
controller = new ThemeController({
themeObject: mockThemeObject,
});
expect(mockThemeFromConfig).toHaveBeenCalledTimes(1);
expect(mockThemeFromConfig).toHaveBeenCalledWith(
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed',
@@ -246,7 +246,7 @@ describe('ThemeController', () => {
);
});
it('should fallback to Superset default theme when BootsrapData themes are empty', () => {
it('should fallback to Superset default theme when BootstrapData themes are empty', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
@@ -267,8 +267,8 @@ describe('ThemeController', () => {
defaultTheme: fallbackTheme,
});
expect(mockThemeFromConfig).toHaveBeenCalledTimes(1);
expect(mockThemeFromConfig).toHaveBeenCalledWith(
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
...fallbackTheme,
algorithm: antdThemeImport.defaultAlgorithm,

View File

@@ -0,0 +1,28 @@
/**
* 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 { utils, writeFile } from 'xlsx';
export default function exportPivotExcel(
tableSelector: string,
fileName: string,
) {
const table = document.querySelector(tableSelector);
const workbook = utils.table_to_book(table);
writeFile(workbook, `${fileName}.xlsx`);
}

View File

@@ -87,40 +87,40 @@ def cidr_translate_filter_func( # noqa: C901
if operator in (FilterOperator.IN, FilterOperator.NOT_IN):
dict_items = [val for val in values if isinstance(val, dict)]
single_values = [val for val in values if not isinstance(val, dict)]
if operator == FilterOperator.IN.value:
if operator == FilterOperator.IN:
cond = col.in_(single_values)
for dictionary in dict_items:
cond = cond | (col <= dictionary["end"]) & (col >= dictionary["start"])
elif operator == FilterOperator.NOT_IN.value:
elif operator == FilterOperator.NOT_IN:
cond = ~(col.in_(single_values))
for dictionary in dict_items:
cond = cond & (col > dictionary["end"]) & (col < dictionary["start"])
return_expression = cond
if len(values) == 1:
value = values[0]
if operator == FilterOperator.EQUALS.value:
if operator == FilterOperator.EQUALS:
return_expression = (
col == value
if not isinstance(value, dict)
else (col <= value["end"]) & (col >= value["start"])
)
if operator == FilterOperator.GREATER_THAN_OR_EQUALS.value:
if operator == FilterOperator.GREATER_THAN_OR_EQUALS:
return_expression = (
col >= value if not isinstance(value, dict) else col >= value["end"]
)
if operator == FilterOperator.GREATER_THAN.value:
if operator == FilterOperator.GREATER_THAN:
return_expression = (
col > value if not isinstance(value, dict) else col > value["end"]
)
if operator == FilterOperator.LESS_THAN.value:
if operator == FilterOperator.LESS_THAN:
return_expression = (
col < value if not isinstance(value, dict) else col < value["start"]
)
if operator == FilterOperator.LESS_THAN_OR_EQUALS.value:
if operator == FilterOperator.LESS_THAN_OR_EQUALS:
return_expression = (
col <= value if not isinstance(value, dict) else col <= value["start"]
)
if operator == FilterOperator.NOT_EQUALS.value:
if operator == FilterOperator.NOT_EQUALS:
return_expression = (
col != value
if not isinstance(value, dict)

View File

@@ -114,25 +114,25 @@ def port_translate_filter_func( # noqa: C901
return_expression: Any
if operator in (FilterOperator.IN, FilterOperator.NOT_IN):
vals_list = itertools.chain.from_iterable(values)
if operator == FilterOperator.IN.value:
if operator == FilterOperator.IN:
cond = col.in_(vals_list)
elif operator == FilterOperator.NOT_IN.value:
elif operator == FilterOperator.NOT_IN:
cond = ~(col.in_(vals_list))
return_expression = cond
if len(values) == 1:
value = values[0]
value.sort()
if operator == FilterOperator.EQUALS.value:
if operator == FilterOperator.EQUALS:
return_expression = col.in_(value)
if operator == FilterOperator.GREATER_THAN_OR_EQUALS.value:
if operator == FilterOperator.GREATER_THAN_OR_EQUALS:
return_expression = col >= value[0]
if operator == FilterOperator.GREATER_THAN.value:
if operator == FilterOperator.GREATER_THAN:
return_expression = col > value[0]
if operator == FilterOperator.LESS_THAN.value:
if operator == FilterOperator.LESS_THAN:
return_expression = col < value[-1]
if operator == FilterOperator.LESS_THAN_OR_EQUALS.value:
if operator == FilterOperator.LESS_THAN_OR_EQUALS:
return_expression = col <= value[-1]
if operator == FilterOperator.NOT_EQUALS.value:
if operator == FilterOperator.NOT_EQUALS:
return_expression = ~col.in_(value)
return return_expression

View File

@@ -541,9 +541,7 @@ class QueryContextProcessor:
# Lets find the first temporal filter in the filters array and change
# its val to be the result of get_since_until with the offset
for flt in query_object_clone.filter:
if flt.get(
"op"
) == FilterOperator.TEMPORAL_RANGE.value and isinstance(
if flt.get("op") == FilterOperator.TEMPORAL_RANGE and isinstance(
flt.get("val"), str
):
time_range = cast(str, flt.get("val"))

View File

@@ -66,7 +66,7 @@ def get_since_until_from_query_object(
time_range = None
for flt in query_object.filter:
if flt.get("op") == FilterOperator.TEMPORAL_RANGE.value and isinstance(
if flt.get("op") == FilterOperator.TEMPORAL_RANGE and isinstance(
flt.get("val"), str
):
time_range = cast(str, flt.get("val"))

View File

@@ -54,7 +54,7 @@ from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.sql import literal_column, quoted_name, text
from sqlalchemy.sql.expression import ColumnClause, Select, TextClause
from sqlalchemy.sql.expression import BinaryExpression, ColumnClause, Select, TextClause
from sqlalchemy.types import TypeEngine
from superset import db
@@ -377,6 +377,10 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
disallow_uri_query_params: dict[str, set[str]] = {}
# A Dict of query parameters that will always be used on every connection
# by driver name
# Whether to use equality operators (= true/false) instead of IS operators
# for boolean filters. Some databases like Snowflake don't support IS true/false
use_equality_for_boolean_filters = False
enforce_uri_query_params: dict[str, dict[str, Any]] = {}
force_column_alias_quotes = False
@@ -1202,6 +1206,78 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
"""
return None
@classmethod
def handle_boolean_filter(
cls, sqla_col: Any, op: str, value: bool
) -> BinaryExpression:
"""
Handle boolean filter operations with engine-specific logic.
By default, uses SQLAlchemy's IS operator (column IS true/false).
Engines that don't support IS for boolean values can override
use_equality_for_boolean_filters to use equality operators instead.
:param sqla_col: SQLAlchemy column element
:param op: Filter operator (IS_TRUE or IS_FALSE)
:param value: Boolean value (True or False)
:return: SQLAlchemy expression for the boolean filter
"""
if cls.use_equality_for_boolean_filters:
return sqla_col == value
else:
return sqla_col.is_(value)
@classmethod
def handle_null_filter(
cls,
sqla_col: Any,
op: utils.FilterOperator,
) -> BinaryExpression:
"""
Handle null/not null filter operations.
:param sqla_col: SQLAlchemy column element
:param op: Filter operator (IS_NULL or IS_NOT_NULL)
:return: SQLAlchemy expression for the null filter
"""
from superset.utils import core as utils
if op == utils.FilterOperator.IS_NULL:
return sqla_col.is_(None)
elif op == utils.FilterOperator.IS_NOT_NULL:
return sqla_col.isnot(None)
else:
raise ValueError(f"Invalid null filter operator: {op}")
@classmethod
def handle_comparison_filter(
cls, sqla_col: Any, op: utils.FilterOperator, value: Any
) -> BinaryExpression:
"""
Handle comparison filter operations (=, !=, >, <, >=, <=).
:param sqla_col: SQLAlchemy column element
:param op: Filter operator
:param value: Filter value
:return: SQLAlchemy expression for the comparison filter
"""
from superset.utils import core as utils
if op == utils.FilterOperator.EQUALS:
return sqla_col == value
elif op == utils.FilterOperator.NOT_EQUALS:
return sqla_col != value
elif op == utils.FilterOperator.GREATER_THAN:
return sqla_col > value
elif op == utils.FilterOperator.LESS_THAN:
return sqla_col < value
elif op == utils.FilterOperator.GREATER_THAN_OR_EQUALS:
return sqla_col >= value
elif op == utils.FilterOperator.LESS_THAN_OR_EQUALS:
return sqla_col <= value
else:
raise ValueError(f"Invalid comparison filter operator: {op}")
@classmethod
def handle_cursor(cls, cursor: Any, query: Query) -> None:
"""Handle a live cursor between the execute and fetchall calls

View File

@@ -83,6 +83,9 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
force_column_alias_quotes = True
max_column_name_length = 256
# Snowflake doesn't support IS true/false syntax, use = true/false instead
use_equality_for_boolean_filters = True
parameters_schema = SnowflakeParametersSchema()
default_driver = "snowflake"
sqlalchemy_uri_placeholder = "snowflake://"

View File

@@ -409,8 +409,8 @@ class ExtraCache:
# IS_NULL and IS_NOT_NULL operators do not have a value
or op
in (
FilterOperator.IS_NULL.value,
FilterOperator.IS_NOT_NULL.value,
FilterOperator.IS_NULL,
FilterOperator.IS_NOT_NULL,
)
)
):
@@ -421,8 +421,8 @@ class ExtraCache:
self.applied_filters.append(column)
if op in (
FilterOperator.IN.value,
FilterOperator.NOT_IN.value,
FilterOperator.IN,
FilterOperator.NOT_IN,
) and not isinstance(val, list):
val = [val]

View File

@@ -1756,7 +1756,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
flt_col = flt["col"]
val = flt.get("val")
flt_grain = flt.get("grain")
op = flt["op"].upper()
op = utils.FilterOperator(flt["op"].upper())
col_obj: Optional["TableColumn"] = None
sqla_col: Optional[Column] = None
if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col:
@@ -1790,8 +1790,8 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
col_type = col_obj.type if col_obj else None
col_spec = db_engine_spec.get_column_spec(native_type=col_type)
is_list_target = op in (
utils.FilterOperator.IN.value,
utils.FilterOperator.NOT_IN.value,
utils.FilterOperator.IN,
utils.FilterOperator.NOT_IN,
)
col_advanced_data_type = col_obj.advanced_data_type if col_obj else ""
@@ -1850,23 +1850,30 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
cond = is_null_cond
else:
cond = sqla_col.in_(eq)
if op == utils.FilterOperator.NOT_IN.value:
if op == utils.FilterOperator.NOT_IN:
cond = ~cond
where_clause_and.append(cond)
elif op == utils.FilterOperator.IS_NULL.value:
where_clause_and.append(sqla_col.is_(None))
elif op == utils.FilterOperator.IS_NOT_NULL.value:
where_clause_and.append(sqla_col.isnot(None))
elif op == utils.FilterOperator.IS_TRUE.value:
where_clause_and.append(sqla_col.is_(True))
elif op == utils.FilterOperator.IS_FALSE.value:
where_clause_and.append(sqla_col.is_(False))
elif op in {
utils.FilterOperator.IS_NULL,
utils.FilterOperator.IS_NOT_NULL,
}:
where_clause_and.append(
db_engine_spec.handle_null_filter(sqla_col, op)
)
elif op == utils.FilterOperator.IS_TRUE:
where_clause_and.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, True)
)
elif op == utils.FilterOperator.IS_FALSE:
where_clause_and.append(
db_engine_spec.handle_boolean_filter(sqla_col, op, False)
)
else:
if (
op
not in {
utils.FilterOperator.EQUALS.value,
utils.FilterOperator.NOT_EQUALS.value,
utils.FilterOperator.EQUALS,
utils.FilterOperator.NOT_EQUALS,
}
and eq is None
):
@@ -1876,36 +1883,35 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
"with comparison operators"
)
)
if op == utils.FilterOperator.EQUALS.value:
where_clause_and.append(sqla_col == eq)
elif op == utils.FilterOperator.NOT_EQUALS.value:
where_clause_and.append(sqla_col != eq)
elif op == utils.FilterOperator.GREATER_THAN.value:
where_clause_and.append(sqla_col > eq)
elif op == utils.FilterOperator.LESS_THAN.value:
where_clause_and.append(sqla_col < eq)
elif op == utils.FilterOperator.GREATER_THAN_OR_EQUALS.value:
where_clause_and.append(sqla_col >= eq)
elif op == utils.FilterOperator.LESS_THAN_OR_EQUALS.value:
where_clause_and.append(sqla_col <= eq)
if op in {
utils.FilterOperator.EQUALS,
utils.FilterOperator.NOT_EQUALS,
utils.FilterOperator.GREATER_THAN,
utils.FilterOperator.LESS_THAN,
utils.FilterOperator.GREATER_THAN_OR_EQUALS,
utils.FilterOperator.LESS_THAN_OR_EQUALS,
}:
where_clause_and.append(
db_engine_spec.handle_comparison_filter(sqla_col, op, eq)
)
elif op in {
utils.FilterOperator.ILIKE.value,
utils.FilterOperator.LIKE.value,
utils.FilterOperator.ILIKE,
utils.FilterOperator.LIKE,
}:
if target_generic_type != GenericDataType.STRING:
sqla_col = sa.cast(sqla_col, sa.String)
if op == utils.FilterOperator.LIKE.value:
if op == utils.FilterOperator.LIKE:
where_clause_and.append(sqla_col.like(eq))
else:
where_clause_and.append(sqla_col.ilike(eq))
elif op in {utils.FilterOperator.NOT_LIKE.value}:
elif op in {utils.FilterOperator.NOT_LIKE}:
if target_generic_type != GenericDataType.STRING:
sqla_col = sa.cast(sqla_col, sa.String)
where_clause_and.append(sqla_col.not_like(eq))
elif (
op == utils.FilterOperator.TEMPORAL_RANGE.value
op == utils.FilterOperator.TEMPORAL_RANGE
and isinstance(eq, str)
and col_obj is not None
):

View File

@@ -55,6 +55,7 @@ from superset.views.filters import (
BaseFilterRelatedRoles,
BaseFilterRelatedUsers,
FilterRelatedOwners,
FilterRelatedTables,
)
logger = logging.getLogger(__name__)
@@ -135,6 +136,7 @@ class RLSRestApi(BaseSupersetModelRestApi):
allowed_rel_fields = {"tables", "roles", "created_by", "changed_by"}
related_field_filters = {
"tables": RelatedFieldFilter("table_name", FilterRelatedTables),
"changed_by": RelatedFieldFilter("first_name", FilterRelatedOwners),
}
base_related_field_filters = {

View File

@@ -102,3 +102,22 @@ class BaseFilterRelatedRoles(BaseFilter): # pylint: disable=too-few-public-meth
return extra_filters(query)
return query
class FilterRelatedTables(BaseFilter): # pylint: disable=too-few-public-methods
"""
A filter to allow searching for related tables.
Use in the api by adding something like:
related_field_filters = {
"tables": RelatedFieldFilter("table_name", FilterRelatedTables),
}
"""
name = lazy_gettext("Table")
arg_name = "tables"
def apply(self, query: Query, value: Optional[Any]) -> Query:
from superset.connectors.sqla.models import SqlaTable
like_value = "%" + cast(str, value) + "%"
return query.filter(SqlaTable.table_name.ilike(like_value))

View File

@@ -555,6 +555,30 @@ class TestRowLevelSecurityWithRelatedAPI(SupersetTestCase):
assert len(result) == len(db_tables)
assert db_table_names == received_tables
def test_rls_tables_related_api_with_filter_matching_birth(self):
self.login(ADMIN_USERNAME)
# Test with filter that should match 'birth_names'
params = prison.dumps({"filter": "birth", "page": 0, "page_size": 100})
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
received_tables = {table["text"] for table in result}
# Should only return tables with 'birth' in the name
assert all("birth" in table_name.lower() for table_name in received_tables)
assert len(result) >= 1 # At least birth_names should be returned
def test_rls_tables_related_api_with_filter_no_matches(self):
self.login(ADMIN_USERNAME)
# Test with filter that should match nothing
params = prison.dumps({"filter": "nonexistent", "page": 0, "page_size": 100})
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
result = data["result"]
assert len(result) == 0
assert data["count"] == 0
def test_rls_roles_related_api(self):
self.login(ADMIN_USERNAME)
params = prison.dumps({"page": 0, "page_size": 100})

View File

@@ -749,12 +749,12 @@ def test_should_generate_closed_and_open_time_filter_range(login_as_admin):
def test_none_operand_in_filter(login_as_admin, physical_dataset):
expected_results = [
{
"operator": FilterOperator.EQUALS.value,
"operator": FilterOperator.EQUALS,
"count": 10,
"sql_should_contain": "COL4 IS NULL",
},
{
"operator": FilterOperator.NOT_EQUALS.value,
"operator": FilterOperator.NOT_EQUALS,
"count": 0,
"sql_should_contain": "COL4 IS NOT NULL",
},
@@ -1122,12 +1122,12 @@ def test__temporal_range_operator_in_adhoc_filter(physical_dataset):
{
"col": "col5",
"val": "2000-01-05 : 2000-01-06",
"op": FilterOperator.TEMPORAL_RANGE.value,
"op": FilterOperator.TEMPORAL_RANGE,
},
{
"col": "col6",
"val": "2002-05-11 : 2002-05-12",
"op": FilterOperator.TEMPORAL_RANGE.value,
"op": FilterOperator.TEMPORAL_RANGE,
},
],
"is_timeseries": False,

View File

@@ -423,3 +423,119 @@ def test_impersonate_user_no_database(mocker: MockerFixture) -> None:
"alice",
"SECRET",
)
def test_handle_boolean_filter_default_behavior() -> None:
"""
Test that BaseEngineSpec uses IS operators for boolean filters by default.
"""
from sqlalchemy import Boolean, Column
from superset.db_engine_specs.base import BaseEngineSpec
# Create a mock SQLAlchemy column
bool_col = Column("test_col", Boolean)
# Test IS_TRUE filter - should use IS operator by default
result_true = BaseEngineSpec.handle_boolean_filter(bool_col, "IS TRUE", True)
assert hasattr(result_true, "left") # IS comparison has left/right attributes
assert hasattr(result_true, "right")
# Test IS_FALSE filter - should use IS operator by default
result_false = BaseEngineSpec.handle_boolean_filter(bool_col, "IS FALSE", False)
assert hasattr(result_false, "left")
assert hasattr(result_false, "right")
def test_handle_boolean_filter_with_equality() -> None:
"""
Test that BaseEngineSpec can use equality operators when configured.
"""
from sqlalchemy import Boolean, Column
from superset.db_engine_specs.base import BaseEngineSpec
# Create a test engine spec that uses equality
class TestEngineSpec(BaseEngineSpec):
use_equality_for_boolean_filters = True
bool_col = Column("test_col", Boolean)
# Test with equality enabled
result_true = TestEngineSpec.handle_boolean_filter(bool_col, "IS TRUE", True)
# Equality comparison should have different structure than IS comparison
assert str(type(result_true)).endswith("BinaryExpression'>")
result_false = TestEngineSpec.handle_boolean_filter(bool_col, "IS FALSE", False)
assert str(type(result_false)).endswith("BinaryExpression'>")
def test_handle_null_filter() -> None:
"""
Test null/not null filter handling.
"""
from sqlalchemy import Boolean, Column
from superset.db_engine_specs.base import BaseEngineSpec
bool_col = Column("test_col", Boolean)
# Test IS_NULL - use actual FilterOperator values
from superset.utils.core import FilterOperator
result_null = BaseEngineSpec.handle_null_filter(bool_col, FilterOperator.IS_NULL)
assert hasattr(result_null, "left")
assert hasattr(result_null, "right")
# Test IS_NOT_NULL
result_not_null = BaseEngineSpec.handle_null_filter(
bool_col, FilterOperator.IS_NOT_NULL
)
assert hasattr(result_not_null, "left")
assert hasattr(result_not_null, "right")
# Test invalid operator
with pytest.raises(ValueError, match="Invalid null filter operator"):
BaseEngineSpec.handle_null_filter(bool_col, "INVALID") # type: ignore[arg-type]
def test_handle_comparison_filter() -> None:
"""
Test comparison filter handling for all operators.
"""
from sqlalchemy import Column, Integer
from superset.db_engine_specs.base import BaseEngineSpec
int_col = Column("test_col", Integer)
# Test all comparison operators - use actual FilterOperator values
from superset.utils.core import FilterOperator
operators_and_values = [
(FilterOperator.EQUALS, 5),
(FilterOperator.NOT_EQUALS, 5),
(FilterOperator.GREATER_THAN, 5),
(FilterOperator.LESS_THAN, 5),
(FilterOperator.GREATER_THAN_OR_EQUALS, 5),
(FilterOperator.LESS_THAN_OR_EQUALS, 5),
]
for op, value in operators_and_values:
result = BaseEngineSpec.handle_comparison_filter(int_col, op, value)
# All comparison operators should return binary expressions
assert str(type(result)).endswith("BinaryExpression'>")
# Test invalid operator
with pytest.raises(ValueError, match="Invalid comparison filter operator"):
BaseEngineSpec.handle_comparison_filter(int_col, "INVALID", 5) # type: ignore[arg-type]
def test_use_equality_for_boolean_filters_property() -> None:
"""
Test that BaseEngineSpec has the correct default value for boolean filter property.
"""
from superset.db_engine_specs.base import BaseEngineSpec
# Default should be False (use IS operators)
assert BaseEngineSpec.use_equality_for_boolean_filters is False

View File

@@ -352,6 +352,48 @@ def test_mask_encrypted_extra_no_fields() -> None:
)
def test_handle_boolean_filter() -> None:
"""
Test that Snowflake uses equality operators for boolean filters instead of IS.
"""
from sqlalchemy import Boolean, Column
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
# Create a mock SQLAlchemy column
bool_col = Column("test_col", Boolean)
# Test IS_TRUE filter - use actual FilterOperator values
from superset.utils.core import FilterOperator
result_true = SnowflakeEngineSpec.handle_boolean_filter(
bool_col, FilterOperator.IS_TRUE, True
)
# The result should be a equality comparison, not an IS comparison
assert (
str(result_true.compile(compile_kwargs={"literal_binds": True}))
== "test_col = true"
)
# Test IS_FALSE filter
result_false = SnowflakeEngineSpec.handle_boolean_filter(
bool_col, FilterOperator.IS_FALSE, False
)
assert (
str(result_false.compile(compile_kwargs={"literal_binds": True}))
== "test_col = false"
)
def test_use_equality_for_boolean_filters_property() -> None:
"""
Test that Snowflake has the use_equality_for_boolean_filters property set to True.
"""
from superset.db_engine_specs.snowflake import SnowflakeEngineSpec
assert SnowflakeEngineSpec.use_equality_for_boolean_filters is True
def test_unmask_encrypted_extra() -> None:
"""
Test that the private keys can be reused from the previous `encrypted_extra`.