Compare commits

..

4 Commits

Author SHA1 Message Date
dependabot[bot]
2f9b0c8305 chore(deps): bump actions/setup-java from 5.2.0 to 5.3.0
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.2.0 to 5.3.0.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](be666c2fcd...ad2b38190b)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-23 07:03:55 +00:00
alex
5916ec4876 fix(plugin-chart-echarts): cross-filter horizontal bars on category not metric (#41104)
Signed-off-by: alex-poor <alex@karo.co.nz>
2026-06-22 20:29:29 -07:00
Harshit-Tiwary
36781fbf47 fix(i18n): wrap table access error message with gettext for translation (#38489)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:28:05 -07:00
Shaitan
215b207ae4 fix(sql): detect set operations and nested selects in subquery check (#38452)
Co-authored-by: sha174n <pedro.sousa@preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:27:32 -07:00
11 changed files with 145 additions and 227 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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'],
},
]);
}
});

View File

@@ -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, {

View File

@@ -1,120 +0,0 @@
# 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.
"""clean up stale can_import permission on ImportExportRestApi
The role-edit screen shows "can import on ImportExportRestApi" twice. Only one
of those is real: the live permission is ``can_import_`` (note the trailing
underscore), derived from the ``import_`` method on ``ImportExportRestApi``.
The visual duplicate is a stale ``can_import`` (no trailing underscore)
permission-view-menu (PVM) for the ``ImportExportRestApi`` view menu, left over
from an older Superset/FAB era and persisted across upgrades. FAB renders
``can_import_`` as "can import " and ``can_import`` as "can import", which look
identical in the UI.
Current code can no longer create the stale row, so this migration removes it.
Before deleting, any role that holds the stale PVM is given the live
``can_import_`` PVM so no role silently loses import access.
This is scoped to the ``ImportExportRestApi`` view menu ONLY. It does not touch
the ``can_import`` permission name globally; ``migrate_roles`` only deletes the
``can_import`` permission row if it has become a true orphan (no remaining
PVMs reference it).
Revision ID: a7d3f1b9c2e4
Revises: 78a40c08b4be
Create Date: 2026-06-23 03:23:55.000000
"""
# revision identifiers, used by Alembic.
revision = "a7d3f1b9c2e4"
down_revision = "78a40c08b4be"
from alembic import op # noqa: E402
from sqlalchemy.exc import SQLAlchemyError # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from superset.migrations.shared.security_converge import ( # noqa: E402
add_pvms,
migrate_roles,
Pvm,
)
VIEW_MENU = "ImportExportRestApi"
# The live permission that should exist for the import endpoint. We ensure it is
# present (it normally is, created on startup by FAB) before reassigning roles to
# it, so the lookup in ``migrate_roles`` cannot resolve to ``None``.
NEW_PVMS = {VIEW_MENU: ("can_import_",)}
# Map the stale PVM (``can_import`` with NO trailing underscore) to the live one
# (``can_import_``). ``migrate_roles`` will, for every role holding the stale
# PVM: add the live PVM (if missing), remove the stale PVM, then delete the
# stale PVM row. The stale ``can_import`` permission row and the view menu are
# only deleted by the helper if they become orphans afterwards.
PVM_MAP = {
Pvm(VIEW_MENU, "can_import"): (Pvm(VIEW_MENU, "can_import_"),),
}
def do_upgrade(session: Session) -> None:
"""Ensure the live ``can_import_`` PVM exists and migrate any role holding the
stale ``can_import`` PVM onto it before the stale row is removed."""
# Guarantee the live PVM exists before we point roles at it. ``add_pvms`` is
# idempotent: it only creates rows that are missing.
add_pvms(session, NEW_PVMS)
# On a clean install the stale PVM does not exist; ``migrate_roles`` resolves
# the old PVM to ``None`` and becomes a no-op, so this is safe to run
# everywhere.
migrate_roles(session, PVM_MAP)
def do_downgrade(session: Session) -> None:
"""Intentionally a no-op: the upgrade only removes a stale duplicate PVM and
leaves the live ``can_import_`` permission untouched, so there is no prior
state worth restoring (recreating the stale row would just reintroduce the
duplicate)."""
# No-op by design. Recreating the stale ``can_import`` PVM and moving roles
# back onto it would reintroduce the very duplicate permission this
# migration removes (and the orphaned row serves no purpose). The live
# ``can_import_`` permission is unaffected by the upgrade, so there is
# nothing meaningful to restore.
pass
def upgrade() -> None:
bind = op.get_bind()
session = Session(bind=bind)
do_upgrade(session)
try:
session.commit()
except SQLAlchemyError as ex:
session.rollback()
raise Exception(f"An error occurred while upgrading permissions: {ex}") from ex
def downgrade() -> None:
bind = op.get_bind()
session = Session(bind=bind)
do_downgrade(session)
try:
session.commit()
except SQLAlchemyError as ex:
session.rollback()
raise Exception(
f"An error occurred while downgrading permissions: {ex}"
) from ex

View File

@@ -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:
"""

View File

@@ -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)
)
)

View File

@@ -1,84 +0,0 @@
# 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 importlib import import_module
import pytest
from superset import db
from superset.migrations.shared.security_converge import (
_add_permission,
_add_permission_view,
_add_view_menu,
_find_pvm,
Role,
)
migration_module = import_module(
"superset.migrations.versions."
"2026-06-23_03-23_a7d3f1b9c2e4_cleanup_stale_can_import_pvm"
)
upgrade = migration_module.do_upgrade
VIEW = "ImportExportRestApi"
STALE_PERM = "can_import" # no trailing underscore
LIVE_PERM = "can_import_" # trailing underscore (the real permission)
@pytest.mark.usefixtures("app_context")
def test_migration_reassigns_roles_and_removes_stale_pvm() -> None:
# Arrange: simulate a metadata DB upgraded from an older era that still has
# the stale ``can_import`` PVM on the ImportExportRestApi view menu, held by
# a role.
view_menu = _add_view_menu(db.session, VIEW)
stale_permission = _add_permission(db.session, STALE_PERM)
stale_pvm = _add_permission_view(db.session, stale_permission, view_menu)
role = Role(name="stale_import_role")
role.permissions.append(stale_pvm)
db.session.add(role)
db.session.commit()
assert _find_pvm(db.session, VIEW, STALE_PERM) is not None
# Act
upgrade(db.session)
# Assert: the live PVM exists, the stale one is gone, and the role kept
# import access by being moved onto the live PVM.
live_pvm = _find_pvm(db.session, VIEW, LIVE_PERM)
assert live_pvm is not None
assert _find_pvm(db.session, VIEW, STALE_PERM) is None
refreshed_role = db.session.query(Role).filter_by(name="stale_import_role").one()
assert live_pvm in refreshed_role.permissions
# Cleanup
db.session.delete(refreshed_role)
db.session.commit()
@pytest.mark.usefixtures("app_context")
def test_migration_is_noop_on_clean_install() -> None:
# No stale PVM present (clean install). The migration must not error and must
# leave the live permission intact.
assert _find_pvm(db.session, VIEW, STALE_PERM) is None
upgrade(db.session)
assert _find_pvm(db.session, VIEW, STALE_PERM) is None
assert _find_pvm(db.session, VIEW, LIVE_PERM) is not None

View File

@@ -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"
)

View File

@@ -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: