Compare commits

...

8 Commits

Author SHA1 Message Date
Evan Rusackas
432fcd1545 fix: silence latent pylint W9001 in generic_loader
The previous commit touched generic_loader.py, so CI pylint now lints
the whole file and flags two pre-existing session.commit() calls that
lack the @transaction decorator. These are example seed-loader scripts,
not command-layer units of work; use the inline disable convention
already established in security/manager.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:01:09 -07:00
Evan
1d2aa9439b fix: explicitly add SqlaTable to session in example loader and tests
With cascade_backrefs=False, assigning `tbl.database = database` no
longer implicitly adds a transient SqlaTable to the session. In
generic_loader.py, `fetch_metadata()` and the outer caller each call
`db.session.merge(tbl)`; previously both calls resolved to the same
already-pending object (added via the backref cascade), but now each
merge() creates a separate transient copy, producing two pending
inserts for the same uuid and a uq_tables_uuid violation on the very
first example loaded (which then poisons the session for every
subsequent example in the same transaction).

Also add the missing explicit `db.session.add()` in two
sqla_models_tests.py tests that relied on the same implicit cascade
to persist a freshly constructed SqlaTable before committing/deleting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 18:03:00 -07:00
Evan Rusackas
6625a7c547 refactor: set cascade_backrefs=False for SqlaTable
Enable the pytest error filter for the "'SqlaTable' object is being
merged into a Session along the backref cascade path" RemovedIn20Warning
and adopt the SQLAlchemy 2.0 behavior for the Database.tables backref.

Constructing SqlaTable(database=...) no longer implicitly merges the new
object into the Database's session. All persisting code paths already
add tables to the session explicitly.

See discussion #40273.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:32:10 -07:00
dependabot[bot]
42a2aede78 chore(deps): bump pandas from 2.1.4 to 2.3.3 (#42192)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 15:42:02 -07:00
lguichard78
c4d50472a9 fix(login): oauth and ldap login ignoring superset_app_root (#34657) 2026-07-18 14:58:22 -07:00
Younsung Lee
cd045886d0 feat(helm): add Gateway API HTTPRoute support (#41073)
Signed-off-by: younsl <cysl@kakao.com>
2026-07-18 14:57:02 -07:00
Jean Massucatto
751f5eb663 fix(dashboard): disable Discard button when there are no unsaved changes (#40832)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-18 14:15:17 -07:00
serdukow
4e098b6f38 fix(ag-grid-table): use t('Main') for time comparison column keys to fix i18n (#40681)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 13:00:53 -07:00
17 changed files with 337 additions and 18 deletions

View File

@@ -125,6 +125,13 @@ Alternatively, perform a fresh install. This is a one-time migration; subsequent
| fullnameOverride | string | `nil` | Provide a name to override the full names of resources |
| globalPodAnnotations | object | `{}` | Global pod annotations to be added to all pods Use this to set annotations that apply to all Superset components Component-specific podAnnotations will be merged with these global annotations |
| hostAliases | list | `[]` | Custom hostAliases for all superset pods # https://kubernetes.io/docs/tasks/network/customize-hosts-file-for-pods/ |
| httproute | object | see `values.yaml` | Gateway API HTTPRoute for exposing Superset via a Gateway. Requires the Gateway API CRDs (gateway.networking.k8s.io/v1) installed in the cluster. |
| httproute.annotations | object | `{}` | Annotations to add to the HTTPRoute |
| httproute.apiVersion | string | `"gateway.networking.k8s.io/v1"` | HTTPRoute apiVersion. Override to gateway.networking.k8s.io/v1beta1 for older Gateway API installations that have not promoted HTTPRoute to v1. |
| httproute.hostnames | list | `[]` | Hostnames that match against the HTTP Host header (templated) |
| httproute.labels | object | `{}` | Additional labels to add to the HTTPRoute |
| httproute.parentRefs | list | `[]` | Gateways this HTTPRoute attaches to |
| httproute.rules | list | `[{"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | Routing rules. Each rule is backed by the Superset service. Set `weight` per rule to leave room for traffic splitting (defaults to 1). When `supersetWebsockets.enabled` is true, an extra rule routing `supersetWebsockets.ingress.path` to the `-ws` service is appended automatically, mirroring the ingress behavior. |
| image.pullPolicy | string | `"IfNotPresent"` | |
| image.repository | string | `"apachesuperset.docker.scarf.sh/apache/superset"` | |
| image.tag | string | `nil` | |

View File

@@ -0,0 +1,83 @@
{{/*
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.
*/}}
{{- if .Values.httproute.enabled -}}
{{- $fullName := include "superset.fullname" . -}}
apiVersion: {{ .Values.httproute.apiVersion | default "gateway.networking.k8s.io/v1" }}
kind: HTTPRoute
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace }}
labels:
app: {{ template "superset.name" . }}
chart: {{ template "superset.chart" . }}
release: {{ .Release.Name }}
heritage: {{ .Release.Service }}
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}
{{- with .Values.httproute.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.httproute.annotations }}
annotations: {{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.httproute.parentRefs }}
parentRefs:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.httproute.hostnames }}
hostnames:
{{- tpl (toYaml .) $ | nindent 4 }}
{{- end }}
rules:
{{- range .Values.httproute.rules }}
- backendRefs:
- group: ''
kind: Service
name: {{ $fullName }}
port: {{ $.Values.service.port }}
weight: {{ .weight | default 1 }}
{{- with .filters }}
filters:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .matches }}
matches:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .timeouts }}
timeouts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- if .Values.supersetWebsockets.enabled }}
- backendRefs:
- group: ''
kind: Service
name: {{ $fullName }}-ws
port: {{ .Values.supersetWebsockets.service.port }}
weight: 1
matches:
- path:
type: PathPrefix
value: {{ .Values.supersetWebsockets.ingress.path }}
{{- end }}
{{- end }}

View File

@@ -246,6 +246,40 @@ ingress:
# hosts:
# - chart-example.local
# -- Gateway API HTTPRoute for exposing Superset via a Gateway.
# Requires the Gateway API CRDs (gateway.networking.k8s.io/v1) installed in the cluster.
# @default -- see `values.yaml`
httproute:
enabled: false
# -- HTTPRoute apiVersion. Override to gateway.networking.k8s.io/v1beta1 for
# older Gateway API installations that have not promoted HTTPRoute to v1.
apiVersion: gateway.networking.k8s.io/v1
# -- Additional labels to add to the HTTPRoute
labels: {}
# -- Annotations to add to the HTTPRoute
annotations: {}
# -- Gateways this HTTPRoute attaches to
parentRefs: []
# - name: my-gateway
# namespace: gateway-system
# sectionName: https
# -- Hostnames that match against the HTTP Host header (templated)
hostnames: []
# - chart-example.local
# -- Routing rules. Each rule is backed by the Superset service. Set `weight`
# per rule to leave room for traffic splitting (defaults to 1). When
# `supersetWebsockets.enabled` is true, an extra rule routing
# `supersetWebsockets.ingress.path` to the `-ws` service is appended
# automatically, mirroring the ingress behavior.
rules:
- matches:
- path:
type: PathPrefix
value: /
# weight: 1
# filters: []
# timeouts: {}
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little

View File

@@ -84,7 +84,7 @@ dependencies = [
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
"pandas[excel]>=2.1.4, <2.4",
"pandas[excel]>=2.3.3, <2.4",
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",

View File

@@ -31,7 +31,7 @@ filterwarnings =
error:Passing a string to Connection.execute\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
# error:"Query" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"SavedQuery" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"SqlaTable" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
error:"SqlaTable" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"SqlMetric" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"TableColumn" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning
# error:"TaggedObject" object is being merged into a Session:sqlalchemy.exc.RemovedIn20Warning

View File

@@ -275,7 +275,7 @@ packaging==25.0
# kombu
# limits
# shillelagh
pandas==2.1.4
pandas==2.3.3
# via apache-superset (pyproject.toml)
paramiko==3.5.1
# via
@@ -331,6 +331,8 @@ pyparsing==3.2.3
# via apache-superset (pyproject.toml)
pysocks==1.7.1
# via urllib3
python-calamine==0.8.2
# via pandas
python-dateutil==2.9.0.post0
# via
# apache-superset (pyproject.toml)

View File

@@ -638,7 +638,7 @@ packaging==25.0
# pytest
# shillelagh
# sqlalchemy-bigquery
pandas==2.1.4
pandas==2.3.3
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -819,6 +819,10 @@ pytest-mock==3.10.0
# via
# apache-superset
# apache-superset-extensions-cli
python-calamine==0.8.2
# via
# -c requirements/base-constraint.txt
# pandas
python-dateutil==2.9.0.post0
# via
# -c requirements/base-constraint.txt

View File

@@ -100,20 +100,21 @@ const processComparisonTotals = (
return totals;
}
const transformedTotals: DataRecord = {};
const mainLabel = t('Main');
totals.map((totalRecord: DataRecord) =>
Object.keys(totalRecord).forEach(key => {
if (totalRecord[key] !== undefined && !key.includes(comparisonSuffix)) {
transformedTotals[`Main ${key}`] =
parseInt(transformedTotals[`Main ${key}`]?.toString() || '0', 10) +
parseInt(totalRecord[key]?.toString() || '0', 10);
transformedTotals[`${mainLabel} ${key}`] =
parseFloat(
transformedTotals[`${mainLabel} ${key}`]?.toString() || '0',
) + parseFloat(totalRecord[key]?.toString() || '0');
transformedTotals[`# ${key}`] =
parseInt(transformedTotals[`# ${key}`]?.toString() || '0', 10) +
parseInt(
parseFloat(transformedTotals[`# ${key}`]?.toString() || '0') +
parseFloat(
totalRecord[`${key}__${comparisonSuffix}`]?.toString() || '0',
10,
);
const { valueDifference, percentDifferenceNum } = calculateDifferences(
transformedTotals[`Main ${key}`] as number,
transformedTotals[`${mainLabel} ${key}`] as number,
transformedTotals[`# ${key}`] as number,
);
transformedTotals[`${key}`] = valueDifference;
@@ -172,6 +173,7 @@ const processComparisonDataRecords = memoizeOne(
originalData: DataRecord[] | undefined,
originalColumns: DataColumnMeta[],
comparisonSuffix: string,
mainLabel: string,
) {
// Transform data
return originalData?.map(originalItem => {
@@ -193,7 +195,7 @@ const processComparisonDataRecords = memoizeOne(
comparisonValue as number,
);
transformedItem[`Main ${origCol.key}`] = originalValue;
transformedItem[`${mainLabel} ${origCol.key}`] = originalValue;
transformedItem[`# ${origCol.key}`] = comparisonValue;
transformedItem[`${origCol.key}`] = valueDifference;
transformedItem[`% ${origCol.key}`] = percentDifferenceNum;
@@ -715,6 +717,7 @@ const transformProps = (
baseQuery?.data,
columns,
comparisonSuffix,
t('Main'),
);
const passedData = isUsingTimeComparison ? comparisonData || [] : data;

View File

@@ -522,9 +522,21 @@ test('should disable both buttons when no actions available', () => {
expect(onRedo).not.toHaveBeenCalled();
});
test('should render the "Discard changes" button', () => {
test('should render the "Discard" button as disabled', () => {
setup(editableState);
expect(screen.getByText('Discard')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /discard/i })).toBeDisabled();
});
test('should enable the "Discard" button when there are unsaved changes', () => {
const unsavedState = {
...editableState,
dashboardState: {
...editableState.dashboardState,
hasUnsavedChanges: true,
},
};
setup(unsavedState);
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
});
test('should render the "Save" button as disabled', () => {

View File

@@ -722,6 +722,7 @@ const Header = (): JSX.Element => {
<Button
css={discardBtnStyle}
buttonSize="small"
disabled={!hasUnsavedChanges}
onClick={discardChanges}
buttonStyle="secondary"
data-test="discard-changes-button"

View File

@@ -133,7 +133,7 @@ export default function Login() {
sessionStorage.setItem('login_attempted', 'true');
// Use standard form submission for Flask-AppBuilder compatibility
SupersetClient.postForm(loginEndpoint, values, '');
SupersetClient.postForm(ensureAppRoot(loginEndpoint), values, '');
};
const getAuthIconElement = (

View File

@@ -28,6 +28,16 @@ from werkzeug.local import LocalProxy
# form.
flask_appbuilder.Model.__allow_unmapped__ = True
# pandas >= 2.2 advertises a minimum SQLAlchemy of 2.0 and silently ignores
# older installations, breaking DataFrame.to_sql / read_sql with SQLAlchemy
# 1.4 engines. Its SQL layer still works with 1.4, so restore support until
# Superset itself requires SQLAlchemy >= 2. Must run before any pandas SQL IO.
from superset.utils.pandas_sqlalchemy_compat import ( # noqa: E402
restore_pandas_sqlalchemy_support,
)
restore_pandas_sqlalchemy_support()
from superset.app import create_app # noqa: E402, F401
from superset.extensions import ( # noqa: E402
appbuilder, # noqa: F401

View File

@@ -1364,7 +1364,14 @@ class SqlaTable(
database: Database = relationship(
"Database",
backref=backref("tables", cascade="all, delete-orphan"),
backref=backref(
"tables",
cascade="all, delete-orphan",
# SQLAlchemy 2.0 behavior: assigning `table.database` no longer
# cascades the SqlaTable into the Database's session; callers must
# add objects to a session explicitly.
cascade_backrefs=False,
),
foreign_keys=[database_id],
)
schema = Column(String(255))

View File

@@ -173,6 +173,13 @@ def load_parquet_table( # noqa: C901
if not tbl:
tbl = SqlaTable(table_name=table_name, database_id=database.id)
# Explicitly add the new table to the session. Assigning `tbl.database`
# below no longer implicitly adds `tbl` to the session (SQLAlchemy 2.0
# behavior, cascade_backrefs=False), so without this, the two
# `db.session.merge()` calls below (one inside `fetch_metadata()`, one
# at the end of this function) would each create a separate transient
# copy of `tbl`, resulting in two pending inserts for the same uuid.
db.session.add(tbl)
# Set the database reference
tbl.database = database
@@ -187,7 +194,7 @@ def load_parquet_table( # noqa: C901
tbl.fetch_metadata()
db.session.merge(tbl)
db.session.commit()
db.session.commit() # pylint: disable=consider-using-transaction
return tbl
@@ -242,7 +249,7 @@ def create_generic_loader(
if description and tbl:
tbl.description = description
db.session.merge(tbl)
db.session.commit()
db.session.commit() # pylint: disable=consider-using-transaction
# Set function name and docstring
loader.__name__ = f"load_{parquet_file}"

View File

@@ -0,0 +1,80 @@
# 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.
"""Compatibility shim letting pandas >= 2.2 use SQLAlchemy 1.4 engines.
pandas 2.2 raised its advertised minimum SQLAlchemy version to 2.0 as a
support-policy change. When an older SQLAlchemy is installed, pandas does not
fail loudly: ``pandas.io.sql`` silently pretends SQLAlchemy is absent, treats
Engine/Connection arguments as raw DBAPI connections, and falls back to its
sqlite-only code path, breaking every ``DataFrame.to_sql`` / ``read_sql``
call site (dataset uploads, example data loading, annotation queries, filter
values).
The pandas SQL layer itself still works with SQLAlchemy 1.4 because it only
uses the API subset common to SQLAlchemy 1.4 and 2.x. Lowering the advertised
minimum back to the pandas 2.1 value restores the working behavior.
This module is obsolete once Superset requires SQLAlchemy >= 2; at that point
the patch becomes a no-op and the module (and its call site in
``superset/__init__.py``) can be deleted.
"""
import logging
import sqlalchemy
from packaging.version import Version
logger = logging.getLogger(__name__)
# The last pandas release line to support SQLAlchemy 1.4 (pandas 2.1)
# required at least this version.
_SQLALCHEMY_MINIMUM = "1.4.16"
def restore_pandas_sqlalchemy_support() -> None:
"""Lower pandas' advertised SQLAlchemy minimum so 1.4 engines work.
Only applies when the installed SQLAlchemy predates 2.0 and pandas
advertises a 2.x minimum; in every other combination this is a no-op.
Safe to call multiple times.
"""
if Version(sqlalchemy.__version__) >= Version("2.0.0"):
# pandas supports SQLAlchemy 2.x natively; nothing to patch.
return
try:
from pandas.compat import _optional
except ImportError:
# The private module moved in a newer pandas; SQL IO with a pre-2.0
# SQLAlchemy will misbehave, so make the situation diagnosable.
logger.warning(
"Could not adjust pandas' minimum SQLAlchemy version; "
"DataFrame.to_sql/read_sql may not accept SQLAlchemy %s engines",
sqlalchemy.__version__,
)
return
advertised = _optional.VERSIONS.get("sqlalchemy")
if advertised and Version(advertised) > Version(_SQLALCHEMY_MINIMUM):
_optional.VERSIONS["sqlalchemy"] = _SQLALCHEMY_MINIMUM
logger.debug(
"Lowered pandas' minimum SQLAlchemy version from %s to %s so "
"pandas SQL IO keeps working with the installed SQLAlchemy %s",
advertised,
_SQLALCHEMY_MINIMUM,
sqlalchemy.__version__,
)

View File

@@ -173,6 +173,7 @@ class TestDatabaseModel(SupersetTestCase):
"'{{ 'xyz_' + time_grain }}' as time_grain",
database=get_example_database(),
)
db.session.add(table)
TableColumn(
column_name="expr",
expression="case when '{{ current_username() }}' = 'abc' "
@@ -275,6 +276,7 @@ class TestDatabaseModel(SupersetTestCase):
table = SqlaTable(
table_name="test_validate_adhoc_sql", database=get_example_database()
)
db.session.add(table)
db.session.commit()
with pytest.raises(QueryObjectValidationError):

View File

@@ -0,0 +1,67 @@
# 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 pandas as pd
from sqlalchemy import create_engine, types
from superset.utils.pandas_sqlalchemy_compat import (
restore_pandas_sqlalchemy_support,
)
def test_to_sql_accepts_sqlalchemy_engine_and_dtypes() -> None:
"""
``DataFrame.to_sql`` must accept a SQLAlchemy engine plus SQLAlchemy
``dtype`` objects regardless of the installed pandas/SQLAlchemy combo.
This is the exact call shape used by dataset uploads
(``BaseEngineSpec.df_to_sql``), example data loading, and the test data
loaders; it breaks when pandas silently rejects the installed SQLAlchemy
as too old (pandas >= 2.2 with SQLAlchemy 1.x) and no compat shim is
applied.
"""
restore_pandas_sqlalchemy_support()
engine = create_engine("sqlite://")
df = pd.DataFrame(
{
"name": ["a", "b"],
"num": [1, 2],
"ds": pd.to_datetime(["2021-01-01", "2021-01-02"]),
}
)
df.to_sql(
"birth_names",
engine,
index=False,
dtype={"ds": types.DateTime(), "name": types.String(255)},
method="multi",
chunksize=100,
)
df.to_sql("birth_names", engine, index=False, if_exists="replace")
result = pd.read_sql_query("SELECT name, num FROM birth_names", engine)
assert result["name"].tolist() == ["a", "b"]
assert result["num"].tolist() == [1, 2]
def test_restore_pandas_sqlalchemy_support_is_idempotent() -> None:
from pandas.compat import _optional
restore_pandas_sqlalchemy_support()
first = _optional.VERSIONS.get("sqlalchemy")
restore_pandas_sqlalchemy_support()
assert _optional.VERSIONS.get("sqlalchemy") == first