mirror of
https://github.com/apache/superset.git
synced 2026-06-23 08:29:18 +00:00
Compare commits
4 Commits
fix/secret
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9b0c8305 | ||
|
|
5916ec4876 | ||
|
|
36781fbf47 | ||
|
|
215b207ae4 |
2
.github/workflows/generate-FOSSA-report.yml
vendored
2
.github/workflows/generate-FOSSA-report.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
2
.github/workflows/license-check.yml
vendored
2
.github/workflows/license-check.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
2
.github/workflows/superset-docs-deploy.yml
vendored
2
.github/workflows/superset-docs-deploy.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
node-version-file: "./docs/.nvmrc"
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
|
||||
@@ -396,3 +396,102 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
|
||||
expect(setDataMaskMock).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
// Test for issue #41102: horizontal bar cross-filter must use the category
|
||||
// value, not the metric. For horizontal bars the data tuple is value-first
|
||||
// (e.g. [100, 'Product A']), so relying on data[0] emitted the metric value.
|
||||
test('emits cross-filter on the category value for a horizontal categorical bar', async () => {
|
||||
const setDataMaskMock = jest.fn();
|
||||
|
||||
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
setDataMask: setDataMaskMock,
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
type: AxisType.Category, // Categorical X-axis
|
||||
},
|
||||
};
|
||||
|
||||
render(<EchartsTimeseries {...propsWithHorizontalXAxis} />);
|
||||
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
|
||||
const clickHandler = props.eventHandlers?.click;
|
||||
if (clickHandler) {
|
||||
clickHandler({
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
dataIndex: 0,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(setDataMaskMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
// Must filter on the category ('Product A'), not the metric value (100)
|
||||
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
|
||||
expect(dataMaskCall.extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Test for issue #41102: the context-menu ("Add cross-filter") path must also
|
||||
// use the category value, not the metric, for a horizontal categorical bar.
|
||||
test('context menu cross-filter uses the category value for a horizontal categorical bar', async () => {
|
||||
const onContextMenuMock = jest.fn();
|
||||
|
||||
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
|
||||
...defaultProps,
|
||||
emitCrossFilters: true,
|
||||
onContextMenu: onContextMenuMock,
|
||||
groupby: [], // No dimensions
|
||||
xAxis: {
|
||||
label: 'category_column',
|
||||
type: AxisType.Category, // Categorical X-axis
|
||||
},
|
||||
};
|
||||
|
||||
render(<EchartsTimeseries {...propsWithHorizontalXAxis} />);
|
||||
|
||||
const lastCall = mockEchart.mock.calls.at(-1);
|
||||
expect(lastCall).toBeDefined();
|
||||
const [props] = lastCall as [EchartsProps];
|
||||
|
||||
const contextMenuHandler = props.eventHandlers?.contextmenu;
|
||||
expect(contextMenuHandler).toBeDefined();
|
||||
if (contextMenuHandler) {
|
||||
await contextMenuHandler({
|
||||
seriesName: 'Sales', // This is the metric name
|
||||
data: [100, 'Product A'], // Horizontal: value first, category second
|
||||
name: 'Product A',
|
||||
event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onContextMenuMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The cross-filter must use the category ('Product A'), not the metric (100)
|
||||
const { crossFilter } = onContextMenuMock.mock.calls[0][2];
|
||||
expect(crossFilter.dataMask.extraFormData.filters).toEqual([
|
||||
{
|
||||
col: 'category_column',
|
||||
op: 'IN',
|
||||
val: ['Product A'],
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -234,9 +234,12 @@ export default function EchartsTimeseries({
|
||||
// Cross-filter by dimension (original behavior)
|
||||
const { seriesName: name } = props;
|
||||
handleChange(name);
|
||||
} else if (canCrossFilterByXAxis && props.data?.[0] != null) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334)
|
||||
handleXAxisChange(props.data[0]);
|
||||
} else if (canCrossFilterByXAxis && props.name != null) {
|
||||
// Cross-filter by X-axis value when no dimensions (issue #25334).
|
||||
// Use `name` (the category-axis value) instead of `data[0]`: for
|
||||
// horizontal bars the data tuple is value-first, so `data[0]` would
|
||||
// be the metric value rather than the category (issue #41102).
|
||||
handleXAxisChange(props.name);
|
||||
}
|
||||
}, TIMER_DURATION);
|
||||
},
|
||||
@@ -318,8 +321,10 @@ export default function EchartsTimeseries({
|
||||
let crossFilter;
|
||||
if (hasDimensions) {
|
||||
crossFilter = getCrossFilterDataMask(seriesName);
|
||||
} else if (canCrossFilterByXAxis && data?.[0] != null) {
|
||||
crossFilter = getXAxisCrossFilterDataMask(data[0]);
|
||||
} else if (canCrossFilterByXAxis && eventParams.name != null) {
|
||||
// Use `name` (the category-axis value), not `data[0]`, so horizontal
|
||||
// bars cross-filter on the category and not the metric (issue #41102).
|
||||
crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
|
||||
}
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
|
||||
@@ -1289,9 +1289,13 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
:returns: The error message
|
||||
"""
|
||||
|
||||
quoted_tables = [f"`{table}`" for table in tables]
|
||||
return f"""You need access to the following tables: {", ".join(quoted_tables)},
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
quoted_tables = [f'"{table}"' for table in tables]
|
||||
return _(
|
||||
"You need access to the following tables: %(tables)s, "
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
) % {
|
||||
"tables": ",".join(quoted_tables),
|
||||
}
|
||||
|
||||
def get_table_access_error_object(self, tables: set["Table"]) -> SupersetError:
|
||||
"""
|
||||
|
||||
@@ -1123,14 +1123,17 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
"""
|
||||
Check if the statement has a subquery.
|
||||
|
||||
Covers explicit subqueries, set operations (``UNION``/``INTERSECT``/
|
||||
``EXCEPT``), and any nested ``SELECT`` regardless of the top-level node
|
||||
type (e.g. when wrapped in parentheses or a set operation).
|
||||
|
||||
:return: True if the statement has a subquery.
|
||||
"""
|
||||
return bool(self._parsed.find(exp.Subquery)) or (
|
||||
isinstance(self._parsed, exp.Select)
|
||||
and any(
|
||||
isinstance(expression, exp.Select)
|
||||
for expression in self._parsed.walk()
|
||||
if expression != self._parsed
|
||||
return (
|
||||
self.is_set_operation()
|
||||
or bool(self._parsed.find(exp.Subquery))
|
||||
or any(
|
||||
select != self._parsed for select in self._parsed.find_all(exp.Select)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -402,8 +402,8 @@ def test_raise_for_access_query_default_schema(
|
||||
)
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== """You need access to the following tables: `public.ab_user`,
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
== 'You need access to the following tables: "public.ab_user", '
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
)
|
||||
|
||||
|
||||
@@ -1454,8 +1454,8 @@ def test_raise_for_access_catalog(
|
||||
sm.raise_for_access(query=query)
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== """You need access to the following tables: `db1.public.ab_user`,
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
== 'You need access to the following tables: "db1.public.ab_user", '
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
)
|
||||
|
||||
query.sql = "SELECT * FROM db2.public.ab_user"
|
||||
@@ -1463,8 +1463,8 @@ def test_raise_for_access_catalog(
|
||||
sm.raise_for_access(query=query)
|
||||
assert (
|
||||
str(excinfo.value)
|
||||
== """You need access to the following tables: `db2.public.ab_user`,
|
||||
`all_database_access` or `all_datasource_access` permission"""
|
||||
== 'You need access to the following tables: "db2.public.ab_user", '
|
||||
"'all_database_access' or 'all_datasource_access' permission"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3549,6 +3549,17 @@ def test_tokenize_kql(kql: str, expected: list[tuple[KQLTokenType, str]]) -> Non
|
||||
"postgresql",
|
||||
True,
|
||||
),
|
||||
# Set operations: a top-level UNION/INTERSECT/EXCEPT is not an
|
||||
# exp.Subquery, so it must be detected explicitly. A predicate fragment
|
||||
# that introduces one (e.g. supplied through a chart filter) must be
|
||||
# flagged.
|
||||
("true UNION SELECT name FROM other_table", "postgresql", True),
|
||||
("1 = 1 UNION ALL SELECT password FROM users", "postgresql", True),
|
||||
("SELECT 1 INTERSECT SELECT 2", "postgresql", True),
|
||||
("SELECT 1 EXCEPT SELECT 2", "postgresql", True),
|
||||
# Nested SELECT under non-Select top-level nodes (e.g. extra
|
||||
# parentheses) must still be detected.
|
||||
("name IN (((SELECT secret FROM s)))", "postgresql", True),
|
||||
],
|
||||
)
|
||||
def test_has_subquery(sql: str, engine: str, expected: bool) -> None:
|
||||
|
||||
Reference in New Issue
Block a user