Compare commits

...
Author SHA1 Message Date
Vitor Avila 93ddafa6b5 fix(OAuth2): Support creating OAuth2 connections via SQLAlchemy URI 2026-08-24 18:10:14 -03:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> dfd057b920 chore(deps-dev): update clickhouse-connect requirement from <2.0,>=1.6.0 to >=1.7.1,<2.0 (#43419)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 13:54:23 -07:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Đỗ Trọng Hải
52571a5b8b chore(deps): bump the rjsf group in /superset-frontend with 3 updates (#43440)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-08-24 13:54:19 -07:00
Joe Li ca3d7670b7 fix(ci): restore scheduled CI checks (#43266) 2026-08-24 12:31:50 -07:00
Joe Li 94855e9626 fix(frontend): ignore stale list responses (#43306) 2026-08-24 12:13:34 -07:00
Joe LiandClaude 7dbf71a379 fix(query-object): reject malformed ad-hoc metrics (#43353)
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-24 12:12:25 -07:00
Joe LiandClaude Sonnet 5 24b95f9ca7 fix(gsheets): correctly format Date-column filter literals (#43355)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 12:12:04 -07:00
Joe Li c635d0754f fix(explore): honor column Label in filter search and pill (#43404) 2026-08-24 12:11:52 -07:00
26 changed files with 764 additions and 56 deletions
@@ -105,6 +105,7 @@ jobs:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-alert: false
summary-always: true
+1 -1
View File
@@ -141,7 +141,7 @@ bigquery = [
"sqlalchemy-bigquery>=1.17.2",
"google-cloud-bigquery>=3.42.3",
]
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
clickhouse = ["clickhouse-connect>=1.7.1, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
+1
View File
@@ -42,6 +42,7 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
PATTERNS = {
"python": [
r"^\.github/workflows/.*python",
r"^\.github/workflows/frontend-bundle-size-nightly\.yml$",
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
r"^docker-compose-image-tag\.yml$",
r"^tests/",
+14 -14
View File
@@ -45,9 +45,9 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^6.7.1",
"@rjsf/core": "^6.8.0",
"@rjsf/utils": "^6.6.2",
"@rjsf/validator-ajv8": "^6.7.1",
"@rjsf/validator-ajv8": "^6.8.0",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -10340,9 +10340,9 @@
"license": "MIT"
},
"node_modules/@rjsf/core": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.7.1.tgz",
"integrity": "sha512-/CQfIGUzcXceBNRhEH3wsTvxcT8dMrjPLXhYSfcJUTftKxOCdisqi2wFwt7LOyIvFvzHUxag0r0xXs/MXS9jmA==",
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.8.0.tgz",
"integrity": "sha512-HZ2e/l/QNcz8PTslBXyGWkiycMe3LgIfwgXo0qO3DPgyFKj4G+obJTKBq/W+DaIUmz1HicvH5WtOdXWWqK+gbA==",
"license": "Apache-2.0",
"dependencies": {
"lodash": "^4.18.1",
@@ -10354,19 +10354,19 @@
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.7.1",
"@rjsf/utils": "^6.8.0",
"react": ">=18"
}
},
"node_modules/@rjsf/utils": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.7.1.tgz",
"integrity": "sha512-6goBapMwyHcXvjLkCnFs4S3P1oKUi1H083BdPk4pDZALFWn5ZdG50ECNfHSddBmL3O0pyx1/WFq1C/MuR7Y54A==",
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.8.0.tgz",
"integrity": "sha512-gHcqPFSHdOz29tZiLlzDvD+Gfq21zVIFBufprSYTiHpUdcVNJEK6+V5aw++FtQncHdDWnTf2YCky/e1Bol2NEQ==",
"license": "Apache-2.0",
"dependencies": {
"@x0k/json-schema-merge": "^1.0.3",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.1",
"fast-uri": "^4.1.2",
"jsonpointer": "^5.0.1",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
@@ -10380,9 +10380,9 @@
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.7.1.tgz",
"integrity": "sha512-oG9reR8VgUUTxfsO8WybZWTjKs6SLUdhmUCp55SXmJvwVbeKZ+Mz4SI+y+T1Mdpbm1kLZWQPRyF2Md97soWXkw==",
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.8.0.tgz",
"integrity": "sha512-F36I952/miMFZzWSlupwFHbl+j+5bVQ3tR6HtBS+vXV10kY5dT3OwTjbFRo4fQM1KpqrUZC0t3/E5CZRX1o1jA==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.20.0",
@@ -10394,7 +10394,7 @@
"node": ">=20"
},
"peerDependencies": {
"@rjsf/utils": "^6.7.1"
"@rjsf/utils": "^6.8.0"
}
},
"node_modules/@rtsao/scc": {
+2 -2
View File
@@ -122,9 +122,9 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^6.7.1",
"@rjsf/core": "^6.8.0",
"@rjsf/utils": "^6.6.2",
"@rjsf/validator-ajv8": "^6.7.1",
"@rjsf/validator-ajv8": "^6.8.0",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -60,7 +60,7 @@ export default function DndAdhocFilterOption({
<OptionWrapper
key={index}
index={index}
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
@@ -43,7 +43,7 @@ import {
DndFilterSelectProps,
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
import { ExpressionTypes } from '../FilterControl/types';
import { Clauses, ExpressionTypes } from '../FilterControl/types';
import { DndItemType } from '../../DndItemType';
import { Datasource } from '../../../types';
import {
@@ -137,6 +137,35 @@ test('renders with value', async () => {
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
});
test('renders the pill using the column verbose_name when one is set', async () => {
const value = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
render(
setup({
value,
columns: [
{
id: 1,
type: 'BIGINT',
type_generic: GenericDataType.Numeric,
column_name: 'num',
verbose_name: 'total_count',
},
],
}),
{
useDndKit: true,
store,
},
);
expect(await screen.findByText('total_count > 500')).toBeInTheDocument();
});
test('renders options with saved metric', async () => {
render(
setup({
@@ -370,4 +370,32 @@ describe('AdhocFilter', () => {
});
expect(adhocFilter.getDefaultLabel()).toBe('');
});
test('uses the column verbose_name in the label when one is given', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
expect(
adhocFilter.getDefaultLabel([
{ column_name: 'num', verbose_name: 'total_count' },
]),
).toBe('total_count > 500');
});
test('falls back to the column_name when no verbose_name is set', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
expect(
adhocFilter.getDefaultLabel([{ column_name: 'num', verbose_name: '' }]),
).toBe('num > 500');
expect(adhocFilter.getDefaultLabel([])).toBe('num > 500');
expect(adhocFilter.getDefaultLabel()).toBe('num > 500');
});
});
@@ -23,7 +23,7 @@ import {
OPERATOR_ENUM_TO_OPERATOR_TYPE,
Operators,
} from 'src/explore/constants';
import { translateToSql } from '../utils/translateToSQL';
import { translateToSql, VerboseColumn } from '../utils/translateToSQL';
import { Clauses, ExpressionTypes } from '../types';
const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
@@ -193,8 +193,8 @@ export default class AdhocFilter {
);
}
getDefaultLabel(): string {
const label = this.translateToSql();
getDefaultLabel(columns?: VerboseColumn[]): string {
const label = this.translateToSql({ columns });
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
}
@@ -202,8 +202,8 @@ export default class AdhocFilter {
return this.translateToSql();
}
translateToSql(): string {
return translateToSql(this as unknown as CoreAdhocFilter);
translateToSql(params: { columns?: VerboseColumn[] } = {}): string {
return translateToSql(this as unknown as CoreAdhocFilter, params);
}
}
@@ -23,6 +23,7 @@ import {
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
@@ -914,3 +915,39 @@ test('dropdown should remain open when clicked after filter is configured', asyn
expect(operatorDropdown).toHaveAttribute('aria-expanded', 'true');
});
test('filters the subject select by column verbose_name as well as column_name', async () => {
setup({
options: [
{
type: 'BIGINT',
column_name: 'num',
verbose_name: 'total_count',
id: 1,
},
{
type: 'VARCHAR(255)',
column_name: 'name',
verbose_name: 'Full Name',
id: 2,
},
],
});
const combobox = screen.getByRole('combobox', { name: 'Select subject' });
userEvent.click(combobox);
await userEvent.type(combobox, 'total');
const dropdown = document.querySelector(
'.ant-select-dropdown-list',
) as HTMLElement;
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
await userEvent.clear(combobox);
await userEvent.type(combobox, 'num');
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
});
@@ -639,7 +639,11 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
('optionName' in column && column.optionName) ||
undefined,
label: renderSubjectOptionLabel(column),
column_name: 'column_name' in column ? column.column_name : undefined,
verbose_name:
'verbose_name' in column ? column.verbose_name : undefined,
}))}
optionFilterProps={['column_name', 'verbose_name']}
{...subjectSelectProps}
/>
);
@@ -71,6 +71,24 @@ test('should render the control label', async () => {
expect(await screen.findByText('value > 10')).toBeInTheDocument();
});
test('should render the control label using the column verbose_name when one is set', async () => {
render(
setup({
...mockedProps,
options: [
{
type: 'DOUBLE',
column_name: 'value',
verbose_name: 'total_count',
id: 3,
},
],
}),
{ useDnd: true, useRedux: true },
);
expect(await screen.findByText('total_count > 10')).toBeInTheDocument();
});
test('should render the remove button', async () => {
render(setup(mockedProps), { useDnd: true, useRedux: true });
const removeBtn = await screen.findByTestId('remove-control-button');
@@ -65,7 +65,7 @@ export default function AdhocFilterOption({
partitionColumn={partitionColumn ?? undefined}
>
<OptionControlLabel
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
onRemove={() =>
onRemoveFilter({
@@ -63,9 +63,35 @@ export const OPERATORS_TO_SQL = {
`= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`,
};
export interface VerboseColumn {
column_name?: string;
verbose_name?: string | null;
}
// Resolves the display label for a filter's subject: the verbose_name of the
// matching column when one is supplied, falling back to the technical
// subject used for SQL generation.
const getDisplaySubject = (
subject: string | { column_name?: string } | null | undefined,
columns?: VerboseColumn[],
) => {
if (!columns) {
return subject ?? undefined;
}
const columnName =
typeof subject === 'object' ? subject?.column_name : subject;
const verboseName = columns.find(
column => column.column_name === columnName,
)?.verbose_name;
return verboseName || (subject ?? undefined);
};
export const translateToSql = (
adhocFilter: AdhocFilter,
{ useSimple }: { useSimple: boolean } = { useSimple: false },
{
useSimple,
columns,
}: { useSimple?: boolean; columns?: VerboseColumn[] } = {},
) => {
if (isSimpleAdhocFilter(adhocFilter) || useSimple) {
const { subject, operator } = adhocFilter as SimpleAdhocFilter;
@@ -81,7 +107,11 @@ export const translateToSql = (
OPERATORS_TO_SQL[operator](adhocFilter)
: // @ts-expect-error TODO: fix missing operator type `NOT LIKE` and `TEMPORAL RANGE`.
OPERATORS_TO_SQL[operator];
return getSimpleSQLExpression(subject, op, comparator);
return getSimpleSQLExpression(
getDisplaySubject(subject, columns),
op,
comparator,
);
}
if (isFreeFormAdhocFilter(adhocFilter)) {
return adhocFilter.sqlExpression;
@@ -22,10 +22,11 @@ import FixedOrMetricControl from '.';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
const createProps = () => ({
@@ -38,10 +38,11 @@ import {
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -29,10 +29,11 @@ import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
const errorMessageRegistry = getErrorMessageComponentRegistry();
@@ -43,6 +43,21 @@ function findEndpoint(spy: jest.SpyInstance, substring: string): string {
return (match[0] as Record<string, string>).endpoint;
}
function deferredJsonResponse() {
let resolveResponse: ((value: JsonResponse) => void) | undefined;
let rejectResponse: ((reason?: unknown) => void) | undefined;
const promise = new Promise<JsonResponse>((resolve, reject) => {
resolveResponse = resolve;
rejectResponse = reject;
});
if (!resolveResponse || !rejectResponse) {
throw new Error('Deferred response handlers were not initialized');
}
return { promise, resolve: resolveResponse, reject: rejectResponse };
}
beforeEach(() => {
jest.restoreAllMocks();
});
@@ -282,6 +297,147 @@ test('useListViewResource: fetchData sets loading to true then false', async ()
});
});
test('useListViewResource: ignores an older response that resolves last', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const toISOString = jest
.spyOn(Date.prototype, 'toISOString')
.mockReturnValueOnce('newer-response-time')
.mockReturnValueOnce('older-response-time');
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
newer.resolve({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }, { id: 2 }], count: 2 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
expect(toISOString).toHaveBeenCalledTimes(1);
});
test('useListViewResource: stale completion keeps the latest request loading', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.resolve({
json: { result: [{ id: 2 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([{ id: 2 }]);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: only the latest request reports an error', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const handleErrorMsg = jest.fn();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', handleErrorMsg, false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.reject('older request failed');
});
expect(handleErrorMsg).not.toHaveBeenCalled();
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.reject('newer request failed');
});
expect(handleErrorMsg).toHaveBeenCalledTimes(1);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: refreshData re-fetches with last config', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
+21 -10
View File
@@ -148,6 +148,7 @@ export function useListViewResource<D extends object = any>(
);
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
const latestRequestIdRef = useRef(0);
const fetchData = useCallback(
({
@@ -156,6 +157,9 @@ export function useListViewResource<D extends object = any>(
sortBy,
filters: filterValues,
}: FetchDataConfig) => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
const isLatest = () => latestRequestIdRef.current === requestId;
const config: FetchDataConfig = {
filters: filterValues,
pageIndex,
@@ -196,24 +200,31 @@ export function useListViewResource<D extends object = any>(
})
.then(
({ json = {} }) => {
if (!isLatest()) {
return;
}
updateState({
collection: json.result,
count: json.count,
lastFetched: new Date().toISOString(),
});
},
createErrorHandler(errMsg =>
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
),
),
createErrorHandler(errMsg => {
if (isLatest()) {
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
);
}
}),
)
.finally(() => {
updateState({ loading: false });
if (isLatest()) {
updateState({ loading: false });
}
});
},
[
+19 -6
View File
@@ -196,13 +196,26 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
# 1. 'metric_name' - name of predefined metric
# 2. { label: 'label_name' } - legacy format for a predefined metric
# 3. { expressionType: 'SIMPLE' | 'SQL', ... } - adhoc metric
def is_str_or_adhoc(metric: Metric) -> bool:
return isinstance(metric, str) or is_adhoc_metric(metric)
# Keys that only ever appear on an ad-hoc metric definition. A dict
# carrying one of these but missing `expressionType` is a malformed
# ad-hoc metric, not a legacy predefined-metric reference, and must
# not be silently collapsed to its label, which would later be
# misread as a request for a saved metric of that name.
adhoc_metric_keys = {"sqlExpression", "aggregate", "column"}
self.metrics = metrics and [
x if is_str_or_adhoc(x) else x["label"] # type: ignore
for x in metrics
]
def normalize_metric(metric: Metric) -> Metric:
if isinstance(metric, str) or is_adhoc_metric(metric):
return metric
if adhoc_metric_keys & metric.keys():
raise QueryObjectValidationError(
_(
"Invalid ad-hoc metric %(label)s: `expressionType` is missing",
label=metric.get("label"),
)
)
return metric["label"] # type: ignore
self.metrics = metrics and [normalize_metric(x) for x in metrics]
def _set_post_processing(
self, post_processing: list[dict[str, Any] | None] | None
+4
View File
@@ -1316,6 +1316,10 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
try:
TestConnectionDatabaseCommand(item).run()
return self.response(200, message="OK")
except OAuth2RedirectError:
# OAuth2 connections pass, so they can be saved. A user later
# can then store an OAuth2 token.
return self.response(200, message="OK")
except (
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
+23 -1
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, TYPE_CHECKING, TypedDict
@@ -32,7 +33,7 @@ from marshmallow.exceptions import ValidationError
from requests import Session
from shillelagh.adapters.api.gsheets.lib import SCOPES
from shillelagh.exceptions import UnauthenticatedError
from sqlalchemy import text
from sqlalchemy import text, types
from sqlalchemy.engine import create_engine
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
@@ -155,6 +156,27 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
oauth2_token_request_uri = "https://oauth2.googleapis.com/token" # noqa: S105
oauth2_exception = (UnauthenticatedError, OAuth2TokenRefreshError)
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
) -> str | None:
"""
Convert a datetime to a SQL literal understood by shillelagh's GSheets
adapter.
``SqliteEngineSpec.convert_dttm`` (inherited via ``ShillelaghEngineSpec``)
has no case for ``types.Date`` and returns ``None``, which makes Superset
fall back to a literal that still carries a time-of-day component. The
GSheets adapter's virtual table layer parses that literal with
``datetime.date.fromisoformat``, which rejects the trailing time and
silently drops the filter value, producing an invalid query against the
Google Sheets API. A bare ``YYYY-MM-DD`` literal is required instead.
"""
sqla_type = cls.get_sqla_column_type(target_type)
if isinstance(sqla_type, types.Date):
return f"'{dttm.date().isoformat()}'"
return super().convert_dttm(target_type, dttm, db_extra=db_extra)
@classmethod
def get_oauth2_authorization_uri(
cls,
@@ -2403,6 +2403,54 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.status_code == 200
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
@with_config({"PREVENT_UNSAFE_DB_CONNECTIONS": False})
def test_test_connection_oauth2(self):
"""
Database API: Test test connection flow with a connection authenticated via
OAuth2.
The test would always raise ``OAuth2RedirectError``, and we can't start the
OAuth2 dance before the connection is saved, so it should return a 200 status.
"""
self.login(ADMIN_USERNAME)
example_db = get_example_database()
masked_encrypted_extra = json.dumps(
{
"oauth2_client_info": {
"id": "client_id",
"secret": "client_secret",
"scope": "some-scope",
"authorization_request_uri": "https://example.org/authorize",
"token_request_uri": "https://example.org/token",
}
}
)
data = {
"database_name": "examples",
"masked_encrypted_extra": masked_encrypted_extra,
"impersonate_user": True,
"sqlalchemy_uri": example_db.safe_sqlalchemy_uri(),
"server_cert": None,
}
url = "api/v1/database/test_connection/"
with (
mock.patch(
"superset.commands.database.test_connection.ping",
side_effect=Exception("Unauthorized"),
),
mock.patch.object(
example_db.db_engine_spec,
"needs_oauth2",
return_value=True,
),
):
rv = self.post_assert_metric(url, data, "test_connection")
assert rv.status_code == 200
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
assert json.loads(rv.data.decode("utf-8")) == {"message": "OK"}
def test_test_connection_failed(self):
"""
Database API: Test test connection failed
@@ -0,0 +1,217 @@
# 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.
# pylint: disable=import-outside-toplevel
from __future__ import annotations
from contextlib import contextmanager
from typing import Any, TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from pytest_mock import MockerFixture
from sqlalchemy import create_engine
from sqlalchemy.orm.session import Session
from sqlalchemy.pool import StaticPool
if TYPE_CHECKING:
from superset.models.core import Database
# A Custom SQL ad-hoc metric exactly as Explore serializes it into a
# ``/api/v1/chart/data`` payload. Its auto-derived ``label`` is the SQL text
# itself, which is what makes the downstream failure mode so confusing.
CUSTOM_SQL_METRIC: dict[str, Any] = {
"expressionType": "SQL",
"sqlExpression": "count(DISTINCT product_line)",
"label": "count(DISTINCT product_line)",
"hasCustomLabel": False,
"optionName": "metric_abc123",
}
# The same metric with ``expressionType`` absent. Every other key still marks it
# unambiguously as an ad-hoc definition rather than a reference to a metric
# saved on the dataset.
MALFORMED_ADHOC_METRIC: dict[str, Any] = {
key: value for key, value in CUSTOM_SQL_METRIC.items() if key != "expressionType"
}
def _chart_data_payload(metric: Any) -> dict[str, Any]:
return {
"datasource": {"id": 1, "type": "table"},
"queries": [
{
"columns": ["source", "target"],
"metrics": [metric],
"row_limit": 100,
}
],
"result_format": "json",
"result_type": "full",
}
def _load_metrics(payload: dict[str, Any]) -> Any:
"""Deserialize a chart data payload the way ``/api/v1/chart/data`` does."""
from superset.charts.schemas import ChartDataQueryContextSchema
with patch(
"superset.common.query_context_factory.DatasourceDAO.get_datasource",
return_value=MagicMock(),
):
query_context = ChartDataQueryContextSchema().load(payload)
return query_context.queries[0].metrics
@pytest.fixture
def database(mocker: MockerFixture, session: Session) -> Database:
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(session.get_bind())
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
future=True,
)
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
connection = engine.raw_connection()
connection.execute("CREATE TABLE t (product_line TEXT, source TEXT, target TEXT)")
connection.commit()
# since we're using an in-memory SQLite database, make sure we always
# return the same engine where the table was created
@contextmanager
def mock_get_sqla_engine(catalog=None, schema=None, **kwargs):
yield engine
mocker.patch.object(database, "get_sqla_engine", new=mock_get_sqla_engine)
return database
def _table(database: Database) -> Any:
from superset.connectors.sqla.models import SqlaTable, TableColumn
return SqlaTable(
database=database,
schema=None,
table_name="t",
columns=[
TableColumn(column_name="product_line"),
TableColumn(column_name="source"),
TableColumn(column_name="target"),
],
)
def test_adhoc_metric_without_expression_type_is_not_read_as_a_saved_metric(
app_context: Any,
) -> None:
"""
An ad-hoc metric that is missing ``expressionType`` must not be silently
reinterpreted as a reference to a metric saved on the dataset.
``QueryObject._set_metrics`` used to rewrite any metric ``dict`` that was
not recognized as ad-hoc down to its ``label``, to support the legacy
``{"label": "saved_metric_name"}`` reference format. ``is_adhoc_metric``
recognizes a metric solely by the presence of ``expressionType``, so an
ad-hoc definition that lacks that one key used to be collapsed into a bare
string. For a Custom SQL metric the label is the SQL text, so the request
was then resolved as if the user had asked for a saved metric literally
named ``count(DISTINCT product_line)``.
``ChartDataAdhocMetricSchema`` declares ``expressionType`` as required, but
``ChartDataQueryObjectSchema.metrics`` is a list of ``fields.Raw``, so that
contract is never enforced at the API boundary. ``_set_metrics`` is the
last point that can tell an ad-hoc-shaped dict apart from a legacy
reference, so it must reject the malformed shape outright rather than
guess.
"""
from superset.exceptions import QueryObjectValidationError
with pytest.raises(
QueryObjectValidationError,
match=r"Invalid ad-hoc metric count\(DISTINCT product_line\): "
r"`expressionType` is missing",
):
_load_metrics(_chart_data_payload(MALFORMED_ADHOC_METRIC))
def test_malformed_adhoc_metric_surfaces_as_a_missing_saved_metric(
database: Database,
) -> None:
"""
Downstream symptom of the coercion above: once the ad-hoc definition has
been reduced to its label, metric resolution looks the label up among the
dataset's saved metrics, fails, and reports the SQL text as a metric name.
"""
from superset.exceptions import QueryObjectValidationError
with pytest.raises(
QueryObjectValidationError,
match=r"Metric 'count\(DISTINCT product_line\)' does not exist",
):
_table(database).get_sqla_query(
columns=["source", "target"],
metrics=["count(DISTINCT product_line)"],
extras={},
filter=[],
granularity=None,
is_timeseries=False,
)
def test_legacy_label_only_metric_still_resolves_to_a_saved_metric_name(
app_context: Any,
) -> None:
"""
Guards the fix from over-correcting: a ``dict`` carrying only ``label`` is
the documented legacy way to reference a metric saved on the dataset, and
must keep collapsing to that name.
"""
metrics = _load_metrics(_chart_data_payload({"label": "sum__num"}))
assert metrics == ["sum__num"]
def test_well_formed_custom_sql_metric_is_preserved(
app_context: Any,
database: Database,
) -> None:
"""
Guards the fix from over-correcting: with ``expressionType`` present the
metric stays an ad-hoc definition and builds SQL without consulting the
dataset's saved metrics.
"""
metrics = _load_metrics(_chart_data_payload(CUSTOM_SQL_METRIC))
assert metrics == [CUSTOM_SQL_METRIC]
assert (
_table(database).get_sqla_query(
columns=["source", "target"],
metrics=metrics,
extras={},
filter=[],
granularity=None,
is_timeseries=False,
)
is not None
)
@@ -17,6 +17,9 @@
# pylint: disable=import-outside-toplevel, invalid-name, line-too-long
from __future__ import annotations
from datetime import datetime
from typing import Any, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
@@ -33,6 +36,8 @@ from superset.sql.parse import Table
from superset.superset_typing import OAuth2ClientConfig
from superset.utils import json
from superset.utils.oauth2 import decode_oauth2_state
from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm
from tests.unit_tests.fixtures.common import dttm # noqa: F401
if TYPE_CHECKING:
from superset.db_engine_specs.base import OAuth2State
@@ -1068,3 +1073,33 @@ def test_validate_parameters_skips_oauth2_connections_with_masked_encrypted_extr
assert errors == []
conn.execute.assert_not_called()
@pytest.mark.parametrize(
"target_type,expected_result",
[
("Date", "'2019-01-02'"),
("DateTime", "'2019-01-02 03:04:05'"),
("UnknownType", None),
],
)
def test_convert_dttm(
target_type: str,
expected_result: str | None,
dttm: datetime, # noqa: F811
) -> None:
"""
A Date-typed column must produce a plain ISO date literal ('YYYY-MM-DD').
Without this, ``SqliteEngineSpec.convert_dttm`` (inherited via
``ShillelaghEngineSpec``) returns ``None`` for ``types.Date``, and Superset falls
back to a full ``'YYYY-MM-DD HH:MM:SS.ffffff'`` literal. shillelagh's virtual
table layer parses that bound value with ``datetime.date.fromisoformat``, which
rejects the trailing time-of-day and silently coerces the constraint to ``None``,
which the GSheets adapter renders as the SQL literal ``null`` -- an unquoted
bareword that Google's Chart API parses as a missing column reference, raising
"Invalid query: NO_COLUMN: null".
"""
from superset.db_engine_specs.gsheets import GSheetsEngineSpec
assert_convert_dttm(GSheetsEngineSpec, target_type, expected_result, dttm)
@@ -0,0 +1,51 @@
# 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.
from pathlib import Path
from typing import Any
import yaml
from scripts import change_detector
WORKFLOW_PATH = (
Path(__file__).resolve().parents[2]
/ ".github/workflows/frontend-bundle-size-nightly.yml"
)
def load_workflow() -> dict[str, Any]:
return yaml.safe_load(WORKFLOW_PATH.read_text())
def test_scheduled_bundle_size_action_uses_read_only_token() -> None:
workflow = load_workflow()
job = workflow["jobs"]["refresh-baseline"]
steps = {step["name"]: step for step in job["steps"]}
benchmark_step = steps["Update bundle size baseline"]
assert benchmark_step["with"]["github-token"] == "${{ secrets.GITHUB_TOKEN }}"
assert workflow["permissions"]["contents"] == "read"
effective_permissions = job.get("permissions", workflow["permissions"])
assert effective_permissions.get("contents") in {None, "read"}
def test_scheduled_bundle_size_changes_trigger_python_tests() -> None:
assert change_detector.detect_changes(
[".github/workflows/frontend-bundle-size-nightly.yml"],
change_detector.PATTERNS["python"],
)