From 490bc015cff1fe52b4927d5c228b11b29ba7d355 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 29 Oct 2026 07:49:50 +0000 Subject: [PATCH 01/12] fix(databricks): migrate legacy connector URIs on upgrade --- UPDATING.md | 4 + docs/src/data/databases.json | 12 +- .../img/databricks-uri-migration/after.png | Bin 0 -> 77922 bytes .../img/databricks-uri-migration/before.png | Bin 0 -> 42189 bytes .../_versioned_data/src/data/databases.json | 12 +- .../databases/supported/databricks.mdx | 26 ++- .../databases/DatabaseModal/index.test.tsx | 98 +++++++++ .../databases/DatabaseModal/index.tsx | 26 ++- superset/db_engine_specs/base.py | 11 + superset/db_engine_specs/databricks.py | 67 ++++-- ...c91d64_migrate_databricks_connector_uri.py | 110 ++++++++++ superset/models/core.py | 5 +- .../db_engine_specs/test_databricks.py | 90 +++++++- .../test_migrate_databricks_connector_uri.py | 207 ++++++++++++++++++ 14 files changed, 622 insertions(+), 46 deletions(-) create mode 100644 docs/static/img/databricks-uri-migration/after.png create mode 100644 docs/static/img/databricks-uri-migration/before.png create mode 100644 superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py create mode 100644 tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py diff --git a/UPDATING.md b/UPDATING.md index ccebb45e4e50..d2ee452dbca9 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -866,6 +866,10 @@ error notice. This does not authorize replay of data-bearing notifications. - The Pinot extra requires pinotdb[sqlalchemy]>=8.0.0,<10.0.0. Earlier releases declare SQLAlchemy below 2 in their SQLAlchemy extra. - The Databricks extra requires databricks-sqlalchemy 2.x (at least 2.0.1). The 1.x dialect requires SQLAlchemy below 2. + Saved `databricks+connector://` connections are automatically migrated to + `databricks://` by `superset db upgrade`, preserving the separately encrypted + token and connection settings. Downgrade restores only connections marked by + this migration, not connections created natively with the new scheme. - `superset deletion-retention force-purge` now exits **1** when the target is blocked by a deletion rule or is not found (the messages are unchanged), so a diff --git a/docs/src/data/databases.json b/docs/src/data/databases.json index b0f1a236eced..6c7c291a2b1d 100644 --- a/docs/src/data/databases.json +++ b/docs/src/data/databases.json @@ -3695,11 +3695,11 @@ "notes": "Requires ODBC driver. For serverless SQL warehouses." }, { - "name": "databricks-dbapi (Legacy)", - "pypi_package": "databricks-dbapi[sqlalchemy]", - "connection_string": "databricks+connector://token:{access_token}@{host}:{port}/{database}", + "name": "Field-based connection", + "pypi_package": "apache-superset[databricks]", + "connection_string": "databricks://token:{access_token}@{host}:{port}/{database}", "is_recommended": false, - "notes": "Legacy connector. Use Python Connector for new deployments." + "notes": "Use the dynamic form to enter connection fields individually." } ], "category": "Cloud Data Warehouses" @@ -3707,7 +3707,7 @@ "engine": "databricks", "engine_name": "Databricks", "engine_aliases": [], - "default_driver": "databricks-sql-python", + "default_driver": "databricks", "supports_file_upload": true, "supports_dynamic_schema": true, "supports_catalog": true, @@ -3779,7 +3779,7 @@ "max_score": 201, "documentation": { "pypi_packages": [], - "connection_string": "databricks+connector://token:{access_token}@{host}:{port}/{database_name}", + "connection_string": "databricks://token:{access_token}@{host}:{port}/{database_name}", "category": "Cloud Data Warehouses" }, "engine": "databricks", diff --git a/docs/static/img/databricks-uri-migration/after.png b/docs/static/img/databricks-uri-migration/after.png new file mode 100644 index 0000000000000000000000000000000000000000..fb8f106eccea8a5a7a9ab35d1d4d4aac4049d564 GIT binary patch literal 77922 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 Am;e9( literal 0 HcmV?d00001 diff --git a/docs/static/img/databricks-uri-migration/before.png b/docs/static/img/databricks-uri-migration/before.png new file mode 100644 index 0000000000000000000000000000000000000000..018112c3f22773bebafbc24248ddd319f8195023 GIT binary patch literal 42189 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 + + +### Upgrading legacy connections + +Workspaces upgrading from 6.0 with a legacy Databricks connection have their +`databricks+connector://` URI automatically migrated to `databricks://` during +`superset db upgrade`. The migration preserves credentials, HTTP path, catalog, +and the rest of the URI. No API access or manual connection recreation is needed. + +Edit the connection to review the saved host, HTTP path, catalog, schema, and +masked access token, or enter a replacement token. When creating a connection +with a SQLAlchemy URI, **Connect this database using the dynamic form instead** +opens the field-based form. + +Downgrading restores the old scheme only on connections converted by this +migration. Connections created natively with `databricks://` are not reverted. + +![Editing a migrated Databricks connection with the field-based form](/img/databricks-uri-migration/after.png) diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index f605e6e328f1..915658bce6a1 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -2054,6 +2054,7 @@ describe('dbReducer', () => { const currentState = dbReducer({}, action); expect(currentState).toEqual({ ...databaseFixture, + configuration_method: ConfigurationMethod.SqlalchemyUri, engine: 'postgres', masked_encrypted_extra: '', parameters: undefined, @@ -2797,3 +2798,100 @@ describe('dbReducer', () => { }); }); }); + +test.each([true, false])( + 'editing an unavailable driver renders a usable form (saved schema: %s)', + async hasSavedSchema => { + const resource: DatabaseObject = { + ...databaseFixture, + id: 10, + backend: 'databricks', + driver: 'connector', + database_name: 'Legacy Databricks', + sqlalchemy_uri: + 'databricks+connector://token:XXXXXXXXXX@example.cloud.databricks.com:443/default', + parameters: { + host: 'example.cloud.databricks.com', + port: 443, + access_token: 'XXXXXXXXXX', + database: 'default', + }, + extra: JSON.stringify({ + engine_params: { + connect_args: { http_path: '/sql/1.0/warehouses/example' }, + }, + }), + parameters_schema: hasSavedSchema + ? { + properties: { + host: { type: 'string' }, + port: { type: 'integer' }, + access_token: { type: 'string' }, + database: { type: 'string' }, + http_path: { type: 'string' }, + }, + } + : {}, + }; + const updateResource = jest.fn().mockResolvedValue(resource); + const hook = jest.spyOn(hooks, 'useSingleViewResource').mockReturnValue({ + state: { loading: false, resource, error: null }, + fetchResource: jest.fn().mockResolvedValue(resource), + createResource: jest.fn(), + updateResource, + clearError: jest.fn(), + setResource: jest.fn(), + }); + // Matches the SQLAlchemy 2 installation: the old driver has no form in + // /available, but its saved schema and masked connection details survive. + fetchMock.get(AVAILABLE_DB_ENDPOINT, { + databases: [ + { + engine: 'databricks', + name: 'Databricks', + default_driver: 'databricks-sql-python', + available_drivers: ['databricks'], + }, + ], + }); + try { + render(, { + useRedux: true, + }); + if (hasSavedSchema) { + expect( + await screen.findByDisplayValue('example.cloud.databricks.com'), + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue('/sql/1.0/warehouses/example'), + ).toBeInTheDocument(); + const token = screen.getByDisplayValue('XXXXXXXXXX'); + fireEvent.change(token, { + target: { value: 'replacement-test-token' }, + }); + expect(token).toHaveValue('replacement-test-token'); + } else { + const uri = await screen.findByTestId('sqlalchemy-uri-input'); + expect(uri).toHaveValue(resource.sqlalchemy_uri); + fireEvent.change(uri, { + target: { value: 'databricks://example.cloud.databricks.com' }, + }); + expect(uri).toHaveValue('databricks://example.cloud.databricks.com'); + await userEvent.click( + screen.getByRole('button', { name: /^finish$/i }), + ); + expect(updateResource).toHaveBeenCalledWith( + 10, + expect.objectContaining({ + configuration_method: ConfigurationMethod.SqlalchemyUri, + sqlalchemy_uri: 'databricks://example.cloud.databricks.com', + }), + false, + ); + } + } finally { + hook.mockRestore(); + fetchMock.clearHistory().removeRoutes(); + } + }, +); diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index c8e41e957d09..3a21b90ebb45 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -572,7 +572,13 @@ export function dbReducer( ...trimmedState, [action.payload.name]: action.payload.value, }; - case ActionType.Fetched: + case ActionType.Fetched: { + const configurationMethod = + action.payload.configuration_method === + ConfigurationMethod.DynamicForm && + !Object.keys(action.payload.parameters_schema?.properties || {}).length + ? ConfigurationMethod.SqlalchemyUri + : action.payload.configuration_method; // convert query to a string and store in query_input query = action.payload?.parameters?.query || {}; // eslint-disable-next-line camelcase @@ -598,7 +604,7 @@ export function dbReducer( return { ...action.payload, engine: action.payload.backend || trimmedState.engine, - configuration_method: action.payload.configuration_method, + configuration_method: configurationMethod, catalog: engineRootCatalog, parameters: { ...(action.payload.parameters || trimmedState.parameters), @@ -616,7 +622,7 @@ export function dbReducer( ...action.payload, masked_encrypted_extra: action.payload.masked_encrypted_extra || '', engine: action.payload.backend || trimmedState.engine, - configuration_method: action.payload.configuration_method, + configuration_method: configurationMethod, parameters: action.payload.parameters || trimmedState.parameters, ssh_tunnel: action.payload.ssh_tunnel || trimmedState.ssh_tunnel, // eslint-disable-next-line camelcase @@ -625,7 +631,7 @@ export function dbReducer( // eslint-disable-next-line camelcase query_input, }; - + } case ActionType.DbSelected: // set initial state for blank form return { @@ -768,7 +774,7 @@ const DatabaseModal: FunctionComponent = ({ const showDBError = validationErrors || dbErrors; const history = useHistory(); - const dbModel: DatabaseForm = + const availableDbModel: DatabaseForm = // TODO: we need a centralized engine in one place // first try to match both engine and driver @@ -787,6 +793,16 @@ const DatabaseModal: FunctionComponent = ({ ) || {}; + // A saved driver's schema can remain usable even when that driver is no longer + // installed. Do not replace it with another driver's form for the same engine. + const dbModel: DatabaseForm = + isEditMode && db?.parameters_schema?.properties + ? { + ...availableDbModel, + parameters: db.parameters_schema as DatabaseForm['parameters'], + } + : availableDbModel; + const handleClearValidationErrors = useCallback(() => { setValidationErrors(null); setHasValidated(false); diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 7eadb4a94d56..0e89d3e95835 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -3217,6 +3217,17 @@ def unmask_encrypted_extra(cls, old: str | None, new: str | None) -> str | None: return json.dumps(new_config) + @classmethod + def get_parameters_from_database( + cls, + database: Database, + encrypted_extra: dict[str, Any], + ) -> dict[str, Any]: + """Return masked form parameters, including engine-specific stored settings.""" + return cls.get_parameters_from_uri( # type: ignore + make_url_safe(database.sqlalchemy_uri), encrypted_extra=encrypted_extra + ) + @classmethod def get_public_information(cls) -> dict[str, Any]: """ diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index d15797eac698..e008c347a4cb 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -742,8 +742,11 @@ def validate_parameters( # type: ignore class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec): engine = "databricks" engine_name = "Databricks" - default_driver = "databricks-sql-python" - drivers = {"databricks-sql-python": "Databricks SQL Python"} + default_driver = "databricks" + drivers = { + "databricks": "Databricks SQL Python", + "databricks-sql-python": "Databricks SQL Python", + } parameters_schema = DatabricksPythonConnectorSchema() @@ -817,13 +820,15 @@ class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec): "notes": "Requires ODBC driver. For serverless SQL warehouses.", }, { - "name": "databricks-dbapi (Legacy)", - "pypi_package": "databricks-dbapi[sqlalchemy]", + "name": "Field-based connection", + "pypi_package": "apache-superset[databricks]", "connection_string": ( - "databricks+connector://token:{access_token}@{host}:{port}/{database}" + "databricks://token:{access_token}@{host}:{port}/{database}" ), "is_recommended": False, - "notes": "Legacy connector. Use Python Connector for new deployments.", + "notes": ( + "Use the dynamic form to enter connection fields individually." + ), }, ], } @@ -897,18 +902,36 @@ def get_parameters_from_uri( # type: ignore "access_token": url.password, "host": url.host, "port": url.port, - "http_path_field": query["http_path"], - "default_catalog": query["catalog"], - "default_schema": query["schema"], + "http_path_field": query.get("http_path", ""), + "default_catalog": query.get("catalog", ""), + "default_schema": query.get("schema", url.database or ""), "encryption": encryption, } + @classmethod + def get_parameters_from_database( + cls, database: Database, encrypted_extra: dict[str, Any] + ) -> dict[str, Any]: + """Expose legacy connect_args in the native connector's field-based form.""" + parameters = super().get_parameters_from_database(database, encrypted_extra) + query = database.url_object.query + for field, key in ( + ("http_path_field", "http_path"), + ("default_catalog", "catalog"), + ("default_schema", "schema"), + ): + if key not in query and key in database.connect_args: + parameters[field] = database.connect_args[key] + return parameters + @classmethod def get_default_catalog( cls, database: Database, ) -> str | None: - return database.url_object.query.get("catalog") + return database.url_object.query.get("catalog") or database.connect_args.get( + "catalog" + ) @classmethod def get_catalog_names( @@ -932,12 +955,22 @@ def adjust_engine_params( if schema: uri = uri.update_query_dict({"schema": schema}) + elif ( + "schema" not in uri.query and "schema" not in connect_args and uri.database + ): + # The SQLAlchemy 2 dialect reads schema from the query, not the path. + uri = uri.update_query_dict({"schema": uri.database}) + + # Edits made in the native form take precedence over legacy connect_args. + for key in ("http_path", "catalog", "schema"): + if key in uri.query: + connect_args.pop(key, None) return uri, connect_args class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): - """Legacy Databricks connector using databricks-dbapi.""" + """Compatibility form for legacy Databricks connections.""" engine = "databricks" engine_name = "Databricks (legacy)" @@ -948,13 +981,11 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): properties_schema = DatabricksNativePropertiesSchema() sqlalchemy_uri_placeholder = ( - "databricks+connector://token:{access_token}@{host}:{port}/{database_name}" + "databricks://token:{access_token}@{host}:{port}/{database_name}" ) metadata = { - "description": ( - "Legacy Databricks connector using the databricks-dbapi driver." - ), + "description": "Field-based configuration for legacy Databricks connections.", "logo": "databricks.png", "homepage_url": "https://www.databricks.com/", "categories": [ @@ -962,9 +993,9 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.PROPRIETARY, ], - "pypi_packages": ["databricks-dbapi[sqlalchemy]"], + "pypi_packages": ["apache-superset[databricks]"], "connection_string": ( - "databricks+connector://token:{access_token}@{host}:{port}/{database_name}?http_path={http_path}" + "databricks://token:{access_token}@{host}:{port}/{database_name}?http_path={http_path}" ), "default_port": 443, "parameters": { @@ -1021,7 +1052,7 @@ def build_sqlalchemy_uri( # type: ignore # False) is required here since this URI is stored/used to actually # connect, not just displayed. return URL.create( - f"{cls.engine}+{cls.default_driver}".rstrip("+"), + cls.engine, username="token", password=parameters.get("access_token"), host=parameters["host"], diff --git a/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py b/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py new file mode 100644 index 000000000000..29d385c78677 --- /dev/null +++ b/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py @@ -0,0 +1,110 @@ +# 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. + +"""Use the SQLAlchemy 2 Databricks dialect for saved legacy connections. + +Revision ID: b8f3a2c91d64 +Revises: f72b6e9c104a + +Only the URI prefix is rewritten. Credentials live in the separate encrypted +password column, which this migration never reads or writes. The extra marker +limits rollback to connections converted by this revision. +""" + +from typing import Any + +import sqlalchemy as sa +from alembic import op + +from superset.utils import json + +revision = "b8f3a2c91d64" +down_revision = "f72b6e9c104a" + +LEGACY_PREFIX = "databricks+connector://" +NATIVE_PREFIX = "databricks://" +MARKER = "migrated_from_databricks_connector_uri" +BATCH_SIZE = 1000 + +# Deliberately independent of application models and encrypted column types. +databases = sa.table( + "dbs", + sa.column("id", sa.Integer), + sa.column("sqlalchemy_uri", sa.String(1024)), + sa.column("extra", sa.Text), +) + + +def _load_extra(value: str | None, database_id: int) -> dict[str, Any]: + """Read extra without silently discarding malformed connection settings.""" + try: + extra = json.loads(value) if value else {} + except ValueError as ex: + raise ValueError(f"Invalid extra for database {database_id}") from ex + if not isinstance(extra, dict): + raise ValueError(f"Invalid extra for database {database_id}") + return extra + + +def _migrate(upgrading: bool) -> None: + """Use keyset batches so rewriting the filter cannot skip later rows.""" + bind = op.get_bind() + last_id = -1 + while True: + query = sa.select(databases).where(databases.c.id > last_id) + if upgrading: + query = query.where(databases.c.sqlalchemy_uri.like(f"{LEGACY_PREFIX}%")) + else: + query = query.where(databases.c.extra.like(f"%{MARKER}%")) + rows = bind.execute(query.order_by(databases.c.id).limit(BATCH_SIZE)).fetchall() + if not rows: + break + for row in rows: + last_id = row.id + extra = _load_extra(row.extra, row.id) + uri = row.sqlalchemy_uri + if upgrading: + # LIKE can be case-insensitive on some metadata databases. + if not uri.startswith(LEGACY_PREFIX): + continue + if MARKER in extra: + raise ValueError( + f"Migration marker already set for database {row.id}" + ) + uri = NATIVE_PREFIX + uri[len(LEGACY_PREFIX) :] + extra[MARKER] = True + else: + if extra.get(MARKER) is not True: + continue + if uri.startswith(NATIVE_PREFIX): + uri = LEGACY_PREFIX + uri[len(NATIVE_PREFIX) :] + del extra[MARKER] + bind.execute( + databases.update() + .where(databases.c.id == row.id) + .values(sqlalchemy_uri=uri, extra=json.dumps(extra)) + ) + + +def upgrade() -> None: + """Convert legacy URIs without parsing or re-encoding their contents.""" + _migrate(upgrading=True) + + +def downgrade() -> None: + """Restore only converted URIs, leaving natively created connections alone.""" + _migrate(upgrading=False) diff --git a/superset/models/core.py b/superset/models/core.py index c6a34ab2f45e..b49a4b51b465 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -435,14 +435,13 @@ def parameters(self) -> dict[str, Any]: # the sqlalchemy_uri # When returning the parameters we should use the masked SQLAlchemy URI and the # masked ``encrypted_extra`` to prevent exposing sensitive credentials. - masked_uri = make_url_safe(self.sqlalchemy_uri) encrypted_config = {} if (masked_encrypted_extra := self.masked_encrypted_extra) is not None: with suppress(TypeError, json.JSONDecodeError): encrypted_config = json.loads(masked_encrypted_extra) try: - parameters = self.db_engine_spec.get_parameters_from_uri( # type: ignore - masked_uri, + parameters = self.db_engine_spec.get_parameters_from_database( + self, encrypted_extra=encrypted_config, ) except Exception: # pylint: disable=broad-except diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index af8b50def4f3..ab720f6266e5 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -19,7 +19,7 @@ from datetime import datetime from importlib import import_module from importlib.metadata import PackageNotFoundError, requires -from typing import Any, Optional +from typing import Any, cast, Optional from unittest.mock import MagicMock from urllib.parse import parse_qs, urlparse @@ -43,6 +43,7 @@ DatabricksHiveEngineSpec, DatabricksNativeEngineSpec, DatabricksPythonConnectorEngineSpec, + DatabricksPythonConnectorParametersType, ) from superset.db_engine_specs.spark import SparkEngineSpec from superset.errors import ErrorLevel, SupersetError, SupersetErrorType @@ -136,9 +137,7 @@ def test_build_sqlalchemy_uri() -> None: sqlalchemy_uri = DatabricksNativeEngineSpec.build_sqlalchemy_uri( parameters, encrypted_extra ) - assert sqlalchemy_uri == ( - "databricks+connector://token:abc12345@my_hostname:1234/test" - ) + assert sqlalchemy_uri == "databricks://token:abc12345@my_hostname:1234/test" def test_parameters_json_schema() -> None: @@ -1646,3 +1645,86 @@ def test_databricks_hive_spec_shares_extended_aggregations() -> None: for aggregate in ("MEDIAN", "STDDEV_SAMP", "VAR_SAMP"): assert DatabricksHiveEngineSpec.get_extended_aggregation_func(aggregate) + + +def test_native_connector_advertises_installed_driver() -> None: + """The SQLAlchemy 2 dialect must expose a field-based connection form.""" + assert DatabricksPythonConnectorEngineSpec.default_driver == "databricks" + assert "databricks" in DatabricksPythonConnectorEngineSpec.drivers + + +def test_converted_connection_parameters_include_legacy_settings() -> None: + """The native form retains masked credentials and settings stored in extra.""" + from superset.models.core import Database + + database = Database( + sqlalchemy_uri="databricks://token:XXXXXXXXXX@example.cloud.databricks.com:443/default", + extra=json.dumps( + { + "engine_params": { + "connect_args": { + "http_path": "/sql/1.0/warehouses/example", + "catalog": "main", + } + } + } + ), + ) + assert database.parameters == { + "access_token": "XXXXXXXXXX", + "host": "example.cloud.databricks.com", + "port": 443, + "http_path_field": "/sql/1.0/warehouses/example", + "default_catalog": "main", + "default_schema": "default", + "encryption": False, + } + assert DatabricksPythonConnectorEngineSpec.get_default_catalog(database) == "main" + + +def test_native_connector_preserves_legacy_schema_and_prioritizes_form_edits() -> None: + """Path schemas reach the dialect; query fields supersede legacy extra.""" + spec = DatabricksPythonConnectorEngineSpec + uri, args = spec.adjust_engine_params( + make_url("databricks://token:masked@host:443/default"), + {"http_path": "/legacy", "catalog": "main"}, + ) + assert uri.query["schema"] == "default" + assert args == {"http_path": "/legacy", "catalog": "main"} + uri, args = spec.adjust_engine_params( + make_url("databricks://host?http_path=/edited&catalog=new&schema=edited"), + {"http_path": "/legacy", "catalog": "main", "schema": "old"}, + ) + assert uri.query == {"http_path": "/edited", "catalog": "new", "schema": "edited"} + assert args == {} + + +def test_native_form_save_preserves_masked_token_and_updates_legacy_settings() -> None: + """Saving masked native fields keeps the token while accepting new settings.""" + from superset.models.core import Database + + database = Database( + extra=json.dumps( + { + "engine_params": { + "connect_args": {"http_path": "/legacy", "catalog": "main"} + } + } + ) + ) + database.set_sqlalchemy_uri("databricks://token:form-test-token@host:443/default") + parameters = database.parameters + parameters["http_path_field"] = "/edited" + parameters["default_catalog"] = "updated" + uri = DatabricksPythonConnectorEngineSpec.build_sqlalchemy_uri( + cast(DatabricksPythonConnectorParametersType, parameters) + ) + database.set_sqlalchemy_uri(uri) + assert database.url_object.password == "form-test-token" # noqa: S105 + assert database.parameters["http_path_field"] == "/edited" + assert database.parameters["default_catalog"] == "updated" + _, args = DatabricksPythonConnectorEngineSpec.adjust_engine_params( + database.url_object, database.connect_args + ) + assert "http_path" not in args + assert "catalog" not in args diff --git a/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py new file mode 100644 index 000000000000..3b84fa979cb2 --- /dev/null +++ b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py @@ -0,0 +1,207 @@ +# 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. +"""Round-trip coverage for saved legacy Databricks connection URIs.""" + +from collections.abc import Iterator +from importlib import import_module +from unittest.mock import patch + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.engine import Engine + +from superset.utils import json + +migration = import_module( + "superset.migrations.versions." + "2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri" +) +LEGACY = migration.LEGACY_PREFIX +NATIVE = migration.NATIVE_PREFIX +MASKED_SUFFIX = "token:XXXXXXXXXX@example.cloud.databricks.com:443/default" + + +@pytest.fixture +def engine() -> Iterator[Engine]: + """Use actual encrypted column types but only the migration's local table.""" + from superset.models.core import Database + + engine = sa.create_engine("sqlite://") + metadata = sa.MetaData() + sa.Table( + "dbs", + metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("sqlalchemy_uri", sa.String(1024)), + sa.Column("extra", sa.Text), + sa.Column("password", Database.__table__.c.password.type), + sa.Column("encrypted_extra", Database.__table__.c.encrypted_extra.type), + ) + metadata.create_all(engine) + yield engine + engine.dispose() + + +def _run(engine: Engine, upgrading: bool) -> None: + """Execute with real Alembic operations and deliberately small batches.""" + with engine.begin() as conn, patch.object(migration, "BATCH_SIZE", 2): + with Operations.context(MigrationContext.configure(conn)): + (migration.upgrade if upgrading else migration.downgrade)() + + +@pytest.mark.parametrize( + "suffix", + [ + MASKED_SUFFIX, + MASKED_SUFFIX + "?z=%2f&catalog=main&http_path=%2Fsql%2F1.0&a=two+words", + "token:p%40ss%2fword@HOST:443/a%2Fb?duplicate=1&duplicate=2", + ], +) +def test_upgrade_preserves_uri_suffix_and_encrypted_credentials( + engine: Engine, + suffix: str, +) -> None: + """The prefix alone changes; separate encrypted credentials survive.""" + from superset.models.core import Database + + table = sa.Table("dbs", sa.MetaData(), autoload_with=engine) + typed_table = sa.table( + "dbs", + sa.column("id", sa.Integer), + sa.column("sqlalchemy_uri", sa.String), + sa.column("extra", sa.Text), + sa.column("password", Database.__table__.c.password.type), + sa.column("encrypted_extra", Database.__table__.c.encrypted_extra.type), + ) + original_extra = { + "engine_params": { + "connect_args": { + "http_path": "/sql/1.0/warehouses/example", + "catalog": "main", + } + } + } + with engine.begin() as conn: + conn.execute( + typed_table.insert().values( + id=1, + sqlalchemy_uri=LEGACY + suffix, + extra=json.dumps(original_extra), + password="migration-test-password", # noqa: S106 + encrypted_extra='{"other":"test-value"}', + ) + ) + before = conn.execute( + sa.select(table.c.password, table.c.encrypted_extra) + ).one() + _run(engine, True) + _run(engine, True) + with engine.connect() as conn: + row = conn.execute(sa.select(table)).one() + assert row.sqlalchemy_uri == NATIVE + suffix + assert json.loads(row.extra) == {**original_extra, migration.MARKER: True} + assert (row.password, row.encrypted_extra) == tuple(before) + decrypted = conn.execute(sa.select(typed_table)).one() + database = Database( + sqlalchemy_uri=decrypted.sqlalchemy_uri, password=decrypted.password + ) + assert database.url_object.password == "migration-test-password" # noqa: S105 + _run(engine, False) + with engine.connect() as conn: + row = conn.execute(sa.select(table)).one() + assert row.sqlalchemy_uri == LEGACY + suffix + assert json.loads(row.extra) == original_extra + assert (row.password, row.encrypted_extra) == tuple(before) + + +def test_upgrade_batches_and_downgrade_only_converted_rows(engine: Engine) -> None: + """Shrinking the matching set must not skip rows or convert native URIs.""" + table = migration.databases + originals = [ + {"id": i, "sqlalchemy_uri": LEGACY + MASKED_SUFFIX, "extra": "{}"} + for i in range(1, 8) + ] + [ + {"id": 8, "sqlalchemy_uri": NATIVE + MASKED_SUFFIX, "extra": "{}"}, + {"id": 9, "sqlalchemy_uri": "postgresql://localhost/db", "extra": "{}"}, + {"id": 10, "sqlalchemy_uri": "databricks+pyodbc://localhost/db", "extra": "{}"}, + ] + with engine.begin() as conn: + conn.execute(table.insert(), originals) + _run(engine, True) + with engine.connect() as conn: + rows = conn.execute(sa.select(table).order_by(table.c.id)).all() + assert all(row.sqlalchemy_uri == NATIVE + MASKED_SUFFIX for row in rows[:8]) + assert all( + json.loads(row.extra).get(migration.MARKER) is True for row in rows[:7] + ) + assert all(row.extra == "{}" for row in rows[7:]) + _run(engine, False) + _run(engine, False) + with engine.connect() as conn: + assert [ + dict(row._mapping) + for row in conn.execute(sa.select(table).order_by(table.c.id)) + ] == originals + + +def test_downgrade_does_not_rewrite_a_subsequently_changed_engine( + engine: Engine, +) -> None: + """Remove the marker without overriding a user's later engine change.""" + table = migration.databases + with engine.begin() as conn: + conn.execute( + table.insert().values( + id=1, sqlalchemy_uri=LEGACY + MASKED_SUFFIX, extra="{}" + ) + ) + _run(engine, True) + with engine.begin() as conn: + assert ( + json.loads(conn.execute(sa.select(table.c.extra)).scalar_one())[ + migration.MARKER + ] + is True + ) + conn.execute(table.update().values(sqlalchemy_uri="postgresql://localhost/db")) + _run(engine, False) + with engine.connect() as conn: + row = conn.execute(sa.select(table)).one() + assert row.sqlalchemy_uri == "postgresql://localhost/db" + assert json.loads(row.extra) == {} + + +@pytest.mark.parametrize("extra", ["not json", "[]", "null"]) +def test_upgrade_rejects_invalid_extra_without_losing_settings( + engine: Engine, extra: str +) -> None: + """Invalid extra must not be overwritten to make room for the marker.""" + table = migration.databases + with engine.begin() as conn: + conn.execute( + table.insert().values( + id=1, sqlalchemy_uri=LEGACY + MASKED_SUFFIX, extra=extra + ) + ) + with pytest.raises(ValueError, match="Invalid extra for database 1"): + _run(engine, True) + with engine.connect() as conn: + row = conn.execute(sa.select(table)).one() + assert row.sqlalchemy_uri == LEGACY + MASKED_SUFFIX + assert row.extra == extra From 7a49c6ce5c980966b6c34ce64af44301e16d05e3 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 29 Oct 2026 09:00:11 +0000 Subject: [PATCH 02/12] fix(databricks): accept migration marker when importing databases Connections converted by the b8f3a2c91d64 migration carry migrated_from_databricks_connector_uri in extra. Exports include it, and ImportV1DatabaseExtraSchema rejected it as an unknown field, so re-importing a converted connection (or any bundle containing it) failed. --- superset/databases/schemas.py | 3 +++ .../test_migrate_databricks_connector_uri.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 13f4f3fcbf3d..56461a2ff715 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -1012,6 +1012,9 @@ def fix_schemas_allowed_for_csv_upload( # pylint: disable=invalid-name per_user_caching = fields.Boolean(required=False) version = fields.String(required=False, allow_none=True) schema_options = fields.Dict(keys=fields.Str(), values=fields.Raw()) + # Set by the b8f3a2c91d64 migration on converted Databricks connections, so + # exports of those connections carry it. + migrated_from_databricks_connector_uri = fields.Boolean(required=False) class ImportV1DatabaseSchema(Schema): diff --git a/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py index 3b84fa979cb2..8e2163da1099 100644 --- a/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py +++ b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py @@ -205,3 +205,26 @@ def test_upgrade_rejects_invalid_extra_without_losing_settings( row = conn.execute(sa.select(table)).one() assert row.sqlalchemy_uri == LEGACY + MASKED_SUFFIX assert row.extra == extra + + +def test_converted_connection_extra_can_be_imported(engine: Engine) -> None: + """Exports of converted connections carry the marker; import must accept it.""" + from superset.databases.schemas import ImportV1DatabaseExtraSchema + + table = migration.databases + original_extra = {"engine_params": {"connect_args": {"http_path": "/sql/1.0"}}} + with engine.begin() as conn: + conn.execute( + table.insert().values( + id=1, + sqlalchemy_uri=LEGACY + MASKED_SUFFIX, + extra=json.dumps(original_extra), + ) + ) + _run(engine, True) + with engine.connect() as conn: + exported_extra = json.loads(conn.execute(sa.select(table.c.extra)).scalar_one()) + assert ImportV1DatabaseExtraSchema().load(exported_extra) == { + **original_extra, + migration.MARKER: True, + } From 9670e47dd08e791b39c041e4fdf534c7136b2046 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 29 Oct 2026 09:16:08 +0000 Subject: [PATCH 03/12] fix(databricks): tolerate malformed connection migration settings --- .../databases/supported/databricks.mdx | 9 +- ...c91d64_migrate_databricks_connector_uri.py | 120 ++++++++++--- .../test_migrate_databricks_connector_uri.py | 160 ++++++++++++++++-- 3 files changed, 246 insertions(+), 43 deletions(-) diff --git a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx index d113bb4a5135..f31f856d416b 100644 --- a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx +++ b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx @@ -140,7 +140,12 @@ masked access token, or enter a replacement token. When creating a connection with a SQLAlchemy URI, **Connect this database using the dynamic form instead** opens the field-based form. -Downgrading restores the old scheme only on connections converted by this -migration. Connections created natively with `databricks://` are not reverted. +Downgrading restores the old scheme on locally converted connections only when +their URI, settings, and modification timestamp still match the migrated state. +Imported connections carrying the conversion marker are also eligible for rollback. Connections created natively with `databricks://` are not reverted. Malformed +`extra` settings do not block the upgrade: their text is preserved, and only the URI +scheme changes. The migration keeps fingerprints in a bookkeeping table so these +connections can also be safely downgraded without parsing their settings. Invalid +settings may still need correction before the connection can execute queries. ![Editing a migrated Databricks connection with the field-based form](/img/databricks-uri-migration/after.png) diff --git a/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py b/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py index 29d385c78677..978ba8d4b514 100644 --- a/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py +++ b/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py @@ -22,14 +22,17 @@ Only the URI prefix is rewritten. Credentials live in the separate encrypted password column, which this migration never reads or writes. The extra marker -limits rollback to connections converted by this revision. +and a migration-owned fingerprint table limit rollback to unchanged converted rows. """ +from datetime import datetime +from hashlib import sha256 from typing import Any import sqlalchemy as sa from alembic import op +from superset.migrations.shared.utils import create_table, drop_table, has_table from superset.utils import json revision = "b8f3a2c91d64" @@ -46,65 +49,128 @@ sa.column("id", sa.Integer), sa.column("sqlalchemy_uri", sa.String(1024)), sa.column("extra", sa.Text), + sa.column("changed_on", sa.DateTime), ) -def _load_extra(value: str | None, database_id: int) -> dict[str, Any]: - """Read extra without silently discarding malformed connection settings.""" +# Fingerprints avoid copying connection settings or credentials into a backup table. +records = sa.table( + "databricks_uri_migration", + sa.column("id", sa.Integer), + sa.column("fingerprint", sa.String(64)), + sa.column("extra_marked", sa.Boolean), +) + + +def _load_extra(value: str | None) -> dict[str, Any] | None: + """Return no object for malformed settings, without replacing their text.""" try: - extra = json.loads(value) if value else {} - except ValueError as ex: - raise ValueError(f"Invalid extra for database {database_id}") from ex - if not isinstance(extra, dict): - raise ValueError(f"Invalid extra for database {database_id}") - return extra + extra = json.loads(value) if value is not None else None + except (ValueError, RecursionError): + return None + return extra if isinstance(extra, dict) else None + + +def _fingerprint(uri: str, extra: str | None, changed_on: datetime | None) -> str: + """Recognize the migrated state, including edits to other database fields.""" + state = json.dumps([uri, extra, str(changed_on)]) + return sha256(state.encode("utf-8")).hexdigest() + + +def _query(upgrading: bool, tracked: bool, last_id: int) -> sa.sql.Select: + """Find source URIs or rollback candidates, including exported markers.""" + query = sa.select(databases).where(databases.c.id > last_id) + if upgrading: + query = query.where(databases.c.sqlalchemy_uri.like(f"{LEGACY_PREFIX}%")) + elif tracked: + query = ( + query.add_columns(records.c.fingerprint, records.c.extra_marked) + .select_from(databases.outerjoin(records, databases.c.id == records.c.id)) + .where( + sa.or_(records.c.id.isnot(None), databases.c.extra.like(f"%{MARKER}%")) + ) + ) + else: + query = query.add_columns( + sa.literal(None).label("fingerprint"), + sa.literal(None).label("extra_marked"), + ).where(databases.c.extra.like(f"%{MARKER}%")) + return query def _migrate(upgrading: bool) -> None: """Use keyset batches so rewriting the filter cannot skip later rows.""" bind = op.get_bind() + tracked = has_table(records.name) last_id = -1 while True: - query = sa.select(databases).where(databases.c.id > last_id) - if upgrading: - query = query.where(databases.c.sqlalchemy_uri.like(f"{LEGACY_PREFIX}%")) - else: - query = query.where(databases.c.extra.like(f"%{MARKER}%")) + query = _query(upgrading, tracked, last_id) rows = bind.execute(query.order_by(databases.c.id).limit(BATCH_SIZE)).fetchall() if not rows: break for row in rows: last_id = row.id - extra = _load_extra(row.extra, row.id) + extra = _load_extra(row.extra) uri = row.sqlalchemy_uri + extra_text = row.extra if upgrading: # LIKE can be case-insensitive on some metadata databases. if not uri.startswith(LEGACY_PREFIX): continue - if MARKER in extra: - raise ValueError( - f"Migration marker already set for database {row.id}" - ) uri = NATIVE_PREFIX + uri[len(LEGACY_PREFIX) :] - extra[MARKER] = True + extra_marked = extra is not None and ( + MARKER not in extra or extra[MARKER] is True + ) + if extra is not None and extra_marked: + extra[MARKER] = True + extra_text = json.dumps(extra) + # Preserve conflicting user settings instead of aborting the upgrade. + bind.execute(records.delete().where(records.c.id == row.id)) + bind.execute( + records.insert().values( + id=row.id, + fingerprint=_fingerprint(uri, extra_text, row.changed_on), + extra_marked=extra_marked, + ) + ) else: - if extra.get(MARKER) is not True: - continue - if uri.startswith(NATIVE_PREFIX): + unchanged = row.fingerprint == _fingerprint( + uri, extra_text, row.changed_on + ) + imported = ( + row.fingerprint is None + and uri.startswith(NATIVE_PREFIX) + and extra is not None + and extra.get(MARKER) is True + ) + if (unchanged or imported) and uri.startswith(NATIVE_PREFIX): uri = LEGACY_PREFIX + uri[len(NATIVE_PREFIX) :] - del extra[MARKER] + if ( + (row.extra_marked or imported) + and extra is not None + and extra.get(MARKER) is True + ): + del extra[MARKER] + extra_text = json.dumps(extra) bind.execute( databases.update() .where(databases.c.id == row.id) - .values(sqlalchemy_uri=uri, extra=json.dumps(extra)) + .values(sqlalchemy_uri=uri, extra=extra_text) ) def upgrade() -> None: - """Convert legacy URIs without parsing or re-encoding their contents.""" + """Convert legacy schemes and track the resulting rows for safe rollback.""" + create_table( + records.name, + sa.Column("id", sa.Integer, primary_key=True, autoincrement=False), + sa.Column("fingerprint", sa.String(64), nullable=False), + sa.Column("extra_marked", sa.Boolean, nullable=False), + ) _migrate(upgrading=True) def downgrade() -> None: - """Restore only converted URIs, leaving natively created connections alone.""" + """Restore unchanged converted URIs, then remove migration bookkeeping.""" _migrate(upgrading=False) + drop_table(records.name) diff --git a/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py index 8e2163da1099..21ba00bae6c8 100644 --- a/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py +++ b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py @@ -50,6 +50,7 @@ def engine() -> Iterator[Engine]: sa.Column("id", sa.Integer, primary_key=True), sa.Column("sqlalchemy_uri", sa.String(1024)), sa.Column("extra", sa.Text), + sa.Column("changed_on", sa.DateTime), sa.Column("password", Database.__table__.c.password.type), sa.Column("encrypted_extra", Database.__table__.c.encrypted_extra.type), ) @@ -156,7 +157,11 @@ def test_upgrade_batches_and_downgrade_only_converted_rows(engine: Engine) -> No with engine.connect() as conn: assert [ dict(row._mapping) - for row in conn.execute(sa.select(table).order_by(table.c.id)) + for row in conn.execute( + sa.select(table.c.id, table.c.sqlalchemy_uri, table.c.extra).order_by( + table.c.id + ) + ) ] == originals @@ -187,24 +192,79 @@ def test_downgrade_does_not_rewrite_a_subsequently_changed_engine( assert json.loads(row.extra) == {} -@pytest.mark.parametrize("extra", ["not json", "[]", "null"]) -def test_upgrade_rejects_invalid_extra_without_losing_settings( - engine: Engine, extra: str +@pytest.mark.parametrize( + "extra", + [ + "not json", + "", + None, + "[]", + "null", + '{"' + migration.MARKER, + '{"' + migration.MARKER + '":false}', + json.dumps({migration.MARKER: True}), + "[" * 1100, + ], +) +def test_upgrade_preserves_invalid_extra_and_downgrades_only_unchanged_rows( + engine: Engine, extra: str | None ) -> None: - """Invalid extra must not be overwritten to make room for the marker.""" - table = migration.databases + """A bad row must not block later batches or lose its original text.""" + from datetime import datetime + + table = sa.Table("dbs", sa.MetaData(), autoload_with=engine) + originals = [ + {"id": i, "sqlalchemy_uri": LEGACY + MASKED_SUFFIX, "extra": extra} + for i in range(1, 7) + ] + [ + {"id": 7, "sqlalchemy_uri": NATIVE + MASKED_SUFFIX, "extra": "not json"}, + {"id": 8, "sqlalchemy_uri": LEGACY + MASKED_SUFFIX, "extra": "{}"}, + ] + with engine.begin() as conn: + conn.execute(table.insert(), originals) + _run(engine, True) + _run(engine, True) with engine.begin() as conn: + rows = conn.execute(sa.select(table).order_by(table.c.id)).all() + assert all(row.sqlalchemy_uri == NATIVE + MASKED_SUFFIX for row in rows) + assert [row.extra for row in rows[:6]] == [extra] * 6 + assert rows[6].extra == "not json" + assert json.loads(rows[7].extra)[migration.MARKER] is True + conn.execute(table.update().where(table.c.id == 2).values(extra="{}")) conn.execute( - table.insert().values( - id=1, sqlalchemy_uri=LEGACY + MASKED_SUFFIX, extra=extra - ) + table.update() + .where(table.c.id == 3) + .values(sqlalchemy_uri=NATIVE + MASKED_SUFFIX + "?catalog=edited") + ) + conn.execute( + table.update() + .where(table.c.id == 4) + .values(sqlalchemy_uri="postgresql://localhost/db") + ) + conn.execute( + table.update() + .where(table.c.id == 5) + .values(changed_on=datetime(2026, 1, 1)) ) - with pytest.raises(ValueError, match="Invalid extra for database 1"): - _run(engine, True) + conn.execute(table.delete().where(table.c.id == 6)) + _run(engine, False) + _run(engine, False) with engine.connect() as conn: - row = conn.execute(sa.select(table)).one() - assert row.sqlalchemy_uri == LEGACY + MASKED_SUFFIX - assert row.extra == extra + rows = {row.id: row for row in conn.execute(sa.select(table))} + assert rows[1].sqlalchemy_uri == LEGACY + MASKED_SUFFIX + if extra == json.dumps({migration.MARKER: True}): + assert json.loads(rows[1].extra) == {} + else: + assert rows[1].extra == extra + assert rows[2].sqlalchemy_uri == NATIVE + MASKED_SUFFIX + assert rows[2].extra == "{}" + assert rows[3].sqlalchemy_uri == NATIVE + MASKED_SUFFIX + "?catalog=edited" + assert rows[4].sqlalchemy_uri == "postgresql://localhost/db" + assert rows[5].sqlalchemy_uri == NATIVE + MASKED_SUFFIX + assert rows[7].sqlalchemy_uri == NATIVE + MASKED_SUFFIX + assert rows[7].extra == "not json" + assert rows[8].sqlalchemy_uri == LEGACY + MASKED_SUFFIX + assert json.loads(rows[8].extra) == {} def test_converted_connection_extra_can_be_imported(engine: Engine) -> None: @@ -228,3 +288,75 @@ def test_converted_connection_extra_can_be_imported(engine: Engine) -> None: **original_extra, migration.MARKER: True, } + + +@pytest.mark.parametrize("edit", ["uri", "extra", "changed_on", "malformed_extra"]) +def test_downgrade_preserves_edits_to_converted_native_connection( + engine: Engine, edit: str +) -> None: + """An edited Databricks row must not be reverted merely due to its marker.""" + from datetime import datetime + + table = sa.Table("dbs", sa.MetaData(), autoload_with=engine) + with engine.begin() as conn: + conn.execute( + table.insert().values( + id=1, sqlalchemy_uri=LEGACY + MASKED_SUFFIX, extra="{}" + ) + ) + _run(engine, True) + with engine.begin() as conn: + changes = { + "uri": {"sqlalchemy_uri": NATIVE + MASKED_SUFFIX + "?catalog=edited"}, + "extra": {"extra": json.dumps({migration.MARKER: True, "edited": True})}, + "changed_on": {"changed_on": datetime(2026, 1, 1)}, + "malformed_extra": {"extra": "not json"}, + } + conn.execute(table.update().values(**changes[edit])) + expected_uri = conn.execute(sa.select(table.c.sqlalchemy_uri)).scalar_one() + _run(engine, False) + with engine.connect() as conn: + row = conn.execute(sa.select(table)).one() + assert row.sqlalchemy_uri == expected_uri + if edit == "malformed_extra": + assert row.extra == "not json" + return + assert migration.MARKER not in json.loads(row.extra) + if edit == "extra": + assert json.loads(row.extra)["edited"] is True + + +def test_downgrade_imported_marker_and_unmarked_native_rows(engine: Engine) -> None: + """Imported markers retain their meaning, unlike unmarked native rows.""" + table = migration.databases + _run(engine, True) + rows = [ + { + "id": 1, + "sqlalchemy_uri": NATIVE + MASKED_SUFFIX, + "extra": json.dumps({migration.MARKER: True}), + }, + {"id": 2, "sqlalchemy_uri": NATIVE + MASKED_SUFFIX, "extra": "{}"}, + {"id": 3, "sqlalchemy_uri": NATIVE + MASKED_SUFFIX, "extra": "not json"}, + ] + with engine.begin() as conn: + conn.execute(table.insert(), rows) + _run(engine, False) + _run(engine, False) + with engine.connect() as conn: + converted, native, malformed = conn.execute( + sa.select(table).order_by(table.c.id) + ) + assert converted.sqlalchemy_uri == LEGACY + MASKED_SUFFIX + assert json.loads(converted.extra) == {} + assert native.sqlalchemy_uri == NATIVE + MASKED_SUFFIX + assert native.extra == "{}" + assert malformed.sqlalchemy_uri == NATIVE + MASKED_SUFFIX + assert malformed.extra == "not json" + assert not sa.inspect(engine).has_table("databricks_uri_migration") + _run(engine, True) + with engine.connect() as conn: + assert all( + row.sqlalchemy_uri == NATIVE + MASKED_SUFFIX + for row in conn.execute(sa.select(table)) + ) From 3cf49d0292176998ec47d567deff7b02f921388e Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 29 Oct 2026 09:36:12 +0000 Subject: [PATCH 04/12] fix(databricks): keep invalid URI handling for database parameters Reading Database.parameters with an unparseable saved URI raised DatabaseInvalidError for every engine before this branch. Moving URI parsing into get_parameters_from_database put it inside the broad exception handler, so such URIs silently returned empty parameters. Validate the URI before the handler so other engines keep their previous behavior. Also remove the PR screenshots from the docs tree (the PR body links them at a pinned commit) and correct the downgrade note in UPDATING.md. --- UPDATING.md | 5 +++-- .../img/databricks-uri-migration/after.png | Bin 77922 -> 0 bytes .../img/databricks-uri-migration/before.png | Bin 42189 -> 0 bytes .../databases/supported/databricks.mdx | 2 -- superset/models/core.py | 2 ++ tests/unit_tests/models/core_test.py | 14 ++++++++++++++ 6 files changed, 19 insertions(+), 4 deletions(-) delete mode 100644 docs/static/img/databricks-uri-migration/after.png delete mode 100644 docs/static/img/databricks-uri-migration/before.png diff --git a/UPDATING.md b/UPDATING.md index d2ee452dbca9..059c92808ab9 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -868,8 +868,9 @@ error notice. This does not authorize replay of data-bearing notifications. - The Databricks extra requires databricks-sqlalchemy 2.x (at least 2.0.1). The 1.x dialect requires SQLAlchemy below 2. Saved `databricks+connector://` connections are automatically migrated to `databricks://` by `superset db upgrade`, preserving the separately encrypted - token and connection settings. Downgrade restores only connections marked by - this migration, not connections created natively with the new scheme. + token and connection settings. Downgrade restores converted connections left + unchanged since the upgrade and imported connections carrying the conversion + marker, not connections created natively with the new scheme. - `superset deletion-retention force-purge` now exits **1** when the target is blocked by a deletion rule or is not found (the messages are unchanged), so a diff --git a/docs/static/img/databricks-uri-migration/after.png b/docs/static/img/databricks-uri-migration/after.png deleted file mode 100644 index fb8f106eccea8a5a7a9ab35d1d4d4aac4049d564..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77922 zcmd43Wn7ef8wEIsh@!xQfIgCn(gMiRkiC^ zz+V>l3q~N2e;~?o&tP7e+p|Po#un#?zYL(@4>?Ab&qHY;SFFceeTH_eyV7RaHgr+G1W|0l1$aVvNnO)>@_d_TOvA zje2?P;Ko%XDb?u2+^qLj`y*IyU!y#HzNkH{Xx_@>_2&=XYvt(o@4kb;dy2-!Gg#Y6 zkL{@{*R_G%+}z;fu)J{ajtK6}!@WJ7FN^W_*_1`h)MwOw78CwwB0N*RP%>o*LtW8P+r!HBOfUyu>6x5cO%OU*#b>@GW;a{njlQRlZcC%SulQkJ-0s3r^iK{d~D_JC6`mYipDX zykI{m)|OOUJwi>w@LLp(B*kTSY-vSyj0#?16xQz^`s^ z@0Ee9llJ<#u)PNDYMENc7byfx=H~SbPfhms&kILLH8nLE85z}|iOL%r8-F4Y$iEVK zn_0zLsktPy-RswPv`&eDZnlIsMz$^P@ou_E$*q2*SFa5E|Q8p;b~w1XS3D} zB^A{xUD09tzdQ3=!lw9HIkYGOx#3o8N<4OUc1GC-7EXoukA}BF4@(Qh7Os<#N%`a5 zKAo#j_B}KK%Z}F3;2FiRZ@FIylljY~^Y#;4uh4g1bn}dU4@DNKlA@B>b&Bo2Rj=Nm z?a`vrXUkC1Z?YvwGDazX^EWp)uP|#5So%Whp7PkBTDRP=SegCLPp}}HEUO`0eqiLd zO_~a|q<61fy_%|Ovj5{-ja5NG0re0SkE!v4uV6^kuIhlZe*Sdg3Jui`Twxj5!_w8O zj9;bBwRTf42U7&jsnaYT%15}lCvm`4ZlY6;f3N67POd@&qfwm`VXH}$T#cXT*TUb0 zgxLJcRi`UwTS(C7BQGb%HZ*KxxnvFPie;Q6R@15DOSRnJs%%epD37eu94%JIZk3OX z{tn#h7k=3l1D9UY;4=n zw&!5Z({_hhG5>=NS#7Yglevs=Y1w?k1%XSpQa2f@F7-3p_d8u^PzKE;nrUus<8A8`(UX57cW-vr)~afYLj$U03pQ zjwmUyawjgjq4LidD~nKtzSmgWmaxaQQ2Ed2JWF4xspBtZw0A#`kVd11;jE<{ndua? zw55`T5D;8ma(h02OXU5YW43;eHm+@k%c0HfMhe4|4ZgirA(Fqw!9~Tn&b30svM_WN z?A=l&dFp0d{y8Q}FZzY3%boISrfXcW=3)8rk(gm?Ih|~FgC|GI@Vb10+GWH)m}7Wa zM)mT-$G1IFW7;*A>exoGGTAqV5TVPU%iNkHvk=ss>w$Y!;phYU@=jR@s-&fK~VF5W;<{Dch z%lh>?m|iYPemDPZCbCND#=N+W&#Q!#nRiAEtnr`%xS@q%P;v_bobo_NDNfIR*UxLd zw^x;Ncpa0wKT)ink+f`ck20o?csS zII3N2%xu*@KQ z0x5A4%5pk|9J7_JmtUf^eM(dC0i&k6DySgYdGecg9}c}XTzx(!b&CaM|Xu+_BFyo)9isW53? z9Z|%#su(|g`ZQ&!%4cIOvDuloeZAq&&THY8L54xsgExxCWxeY}BvdG!k{Az#Prk3t zJxKxr(i>Diyb!HOa=up$Lgrl_QwhI6?>A4Ex?(xQv^})IRRM9*f;hEabehArYWa|!s+V1`VVkx?H`r$k7M-e1lB?iY5yo!Z z;1qd>YhSNB1VMXnnISrwo3+c3NwNY9RxC%wB)HuO|^I3>R*wiXq?jyd8 zVZoLOnKLZ$$^OPmvx;K-{7sFnCM*_9v@M}3TVYV6U^F#7Z6vOj@yU3!(4+hks?q;P?h%77yOGv*+6>RqL=MnFWN$ij_hX3wd;mSCSjL_tKYvXD&i}f1N zB;wGk$gIntdFzc%@ajooM?5i9hqn~t)e+(uwv1x|NijCnI3660Vho*j)bHg)O+-+U zY6^zHb{4zgv%eny(?%{$I!6V2Pw?NGGb|@k38A>TOV+Cpchg? zF7Zc%>uuik_GG8H|JiZwHfSLy3+oZ;icIJO=X@7-mxH^r@0*z01aY)1;TFxSM?~l zK&raAR4mnDJRB1XyJ-y>SHyoqL*0h+ZEql>3nL3_0VPVRKwQS#s zO0&vpJ?cyoG&*~P$9`Ogv!nkDYW31uQw0R(}d6W9@5G z-q*>_?c%%2kvYx3d5WejiCK+smh8NrQtv4}zDc?{rh!neO@rmxoHXqZrtswSlG4eTwB1p>6|wU`MdUztGoBC2oVgXQDa zqM!2oBd-J%Npo*t=%8--twnE^0nX)Ug}vr98`>8X9Jl1CsD*IwjaOrr1Qpdw!1CO| zVRdXuWaJ60@rE*j6mVSUoqmOq{&V3Tl7 zYq6xiNP9oL+@!f~MP$#6U3zz}Rorbom#n|awAIHFijP8=*^ zkr|sc&ai(2UNFJ}*UFEQ^{>4LmNMFX*m5j;6XbWHHp5C?16jj+YRzi%LAXC8zqhmT zVmh27rbE9^Pxl55vjfiXHszPe1b)7nPP5bSKsJ=E_dOkfcm7t!I?cCexzftPVyU-y z=tQ=Anq{&b=?)S0OZz!q)$AE!5N|x~X!CZx$Rm00C!z|s0V*P#56iqjxft^yNacYY zebj-t1%&D$)*ZylA4=_W2Q@p(^RSQTYr{N<=2(&s%bFaTm5fUGZlA9CeN|KW=ztQU zGj-McdG}(a?sev2P4i9?P)X_RpzS_+lZd63s24VUFs9BcXu*`j)KOnxAqP`0V>e2w zdwE`Qg>smkaR?#PIi!%>>b6rJdb;%;AiE718oONf)4Wke4Z}sc@ zj2ZLnmrwO6ttNT0$t9gd^kBhTVGInyrx6T4xlEWu@(mZSD61Pklm3+GciY6Z^D6$J z@+_}O?*wY_Hn}JGF2>Yt--tR1se(gWnx;2+1T|rw;O5gCNT^-oBXy1ClUAm9@mdBk zv|*_kwo{|p+K6`|=}D%TPuk)-56UNUtrg_6vYvxvm90~t4SQ=-y;{X*DW1Bm@H)(W zTC4h{gJAxS85+v0lPPR7A{UZLQ?ILKq-HcY^HV_(bHhKX&Hp65@`3uRaS%QNS8n2` z^keLlnjJyaksiYV_qSkE5g2|;@{~S#k_F09r~+?H_!g=X0u##EN_edJ3Gu@@w;V&h z7)J51Z{@b78^3ss62K)x#HRCC(rs9-DimWM1!iTmX)W<>%>0iF*r$IX%KbyjFpr1F zKZgaI)j!tfp{lfns_J|0Ip=K!Wt>vWoqZ+L;4!ll`_w^KF2~qk1{-6jl(Lv;qjzIc z$*ci);5qA_;*Hi!{%&>FyOznKZ%{AjbFf)7fwdVeu581r^cJ6$9v1W5@&Ay;Zzp>; zKFX}4aRcjjmRJzT+V@U_-Y3oa8Yu)4C;MuM{Lz`;xT8>t?;LYZP2o}k${Nare)Vi6 zHYR%GsGO{{lu65?@bltpVumM*_XN)dpI#WdJ7XA@a!GLW&$_=xP;)-+N+S@<76Vvs zkFyEDz<~o8l-exRf#5Q4yN&TK((}S*Fab(lx3Qak%#ud+4)EL+u?OU_Z1YSHR&1{- zeyM2sH?y|`vyd~-nPO{~x0A2JvS{9597Ay-jOBtq!#KKs(1llteyU`9@Z^b>&3ySx zsb+?eP4YRSh>920i3;Nj@7NeE8O#)2k|=y93r*zn`E&_=HxSpAfde)mUp}xCxZWsh_x*+X>fWl@X?Of_uJU5+5-W>J*YTL zhkv$*=fLLTNAs@0M?Q-l)OKI+j-84FmlK~e(sdx;;Y5Qo+`-B>JsbhjSYH}HmNb%@ zQx_m^H7_M~Xl>77fS#@J>yS2XEa#4`u<@g&w(yGh^JwkW18~Wvs2|l9)lRlB*CCGG z`S!rsCU?shV=9zV5=Qg4ZWN?ZL2ZYHv)j@IDExoiE@r(99G$0O)n@6p#o(T!gSm`K z>2`>m!?+k#C;&g_XD6ONX1wb=TD?0l%7E4`g(4Dq^SBuefE2G@36Kc*X$6KfO_TM`6|4HB3v0=1tS{iLrETu6VfQW zK$0CIl#0|j^7}d;ib**Zg(r7OJ(P9TW_SU;wfD#{dfnp<#JQZ^&(gm4VAv@T*J-Z)kYXN`u8J0C!2;8`dp#A3zP= z2f;~^OZ~|;jG;LLc+YctiWd+pb+_oTf2p+H*M~lOlicvM zNOp|AO=f6|6(*wF)SVt}mo+stRaxI3FUiJ#wRTS+@pn+Us7-BFW5?BlS&o}~8VCD1 zm?b{xa#}kYTMvuQXj9E+s*;bq!_nQ2-z?3|z3*D)IraX$y#KPc&)wsT_;jG^TMLEb5;BPe!I_s zxm;iM-m|q1CfjA~Z{M>cRI|dErl0HNjg(<5gd3al=_Z^VVd_%~Fh7S$U!Lz7{6k)L z6&VM^w)KaA=MiTkDp9j$gzrK_)1zc5`x04WSuC!^eD93p6sa9VDO*+=1riIhIKbF) zM>G=Q(z$qb+93%q_>JUzCV7T*?N*-yH?MEmhWV6(eJA@YwcdM{_TELI$xA96_9U(a za$N2HO|Lh|oUF$&NGbR5%(1--9a6g=T`!vY>1F$m_0rS57h+<(I4__twcZ)_c91`{ zyT(8GSwjh4QHLxpR!4~9QezG?90bYj{d8P0Lg(Kjd1`g|#1{T3FTmhISv1WmZSxDz z1;y^lmSu%?uD|zrhdoLca|e5ISlgOM3nE}sO_Uq;mr-xBhwhFPMS9x;jwC!LVGPgG zq=5fwi^p^G&6nWIOxpZJ7u}@|ss#^s=PT9D!dbK5r*wU;PF|{*#HT!8=|N_RxkK^j zk7K%mejy~(4$OlSIOll~z9Ci{e+awjd^GpUXB?cBVM+n`jS|_=TU9*2gxV7i_MjH= zjZ+3hN#0waj<;!e`Sxatk4lY>q=m-1QtkKV4c7;wXI%&kAz3*T4K2Ez{W;H2`$2R- z%vx{C;7kz{I-k?iA!o~?RN?q;)%iuW#A6nRuCzU?0mg&9Fl<8B8}G1iS4R3VI9o2C zLU$xtH^xgVsPMOf&d`LgbJXwa2YSi{V;`rBYStP z1cKaSy#OY22Jb>VA!5Q28IA>$cR3fUWBA66U*|i&BWJA0#;;?J*_rJDuTobSF(T%^ zX{~5oW6Nk$T4(nS+ALe6;g*iG#Vy>t%MBw%TMQ*k8>!g}y!>+OU=jl8pV1Wg)tMDJ z?@U|=Py1>96x3IjOT~5=uu{wIHxqCJH_*S>2uGe9jsmW2tLQteJ$Ly_%egP|IqI(D zG+cdvaH3jI^(C{3WuqPE9{q$`0<`I>1G{DS!SLvsA!B{vgz&H+6#CwxH#v>pN(eEg zRPF}CKAvvYcefQEB%;Z$S{ItEZkCtGs;MhG&M@3gjJHmGZ+3CIzd5d|T14KK?|HUY z(4c7M20%uMWK$bc=I+;WY{SMVe8g{n1_F`xa6dVfZt7&_6}B3B%}Yc=!bion-jEfb zoQ}|zgbSzTWPx#-s9Yyz@RDw4fK%8ioyq7*7S(44J}7w=6Y*%<2LbrI!>U6$&bADV zC$mTLn4Elb(S$2!+@7N09bw_&!^zOrat_I1XRKi9WLa9XT?l7<6?X}}mqPH0xTnD6 zK`%wO#o<5Lt-K#n>*?83Zen04Z4aqZ8Quf z*9{-3KYN>2BLj-ymljXZfv{0w&GLWc7v*@`@Mlcs)?aNXX4da<}?oWOPG7O5p4)eiqSHHM#>7xih_ zK}Sa`tBgs`R}~rin`rORUDt@>T|(?lY#<*QsskMq0^U`VTy&BWR?KAqM>}9cpC81m7gy0icOnix-ajM_xqfG#B4?$(SI0S ze6e*Hm)!Lk6QoB`;U>-O)5A};B1@whpAfrrc*9;5f9&mTN8Unl$IAP&+)Aq-Zf!Fp zqnH-w(Zk+%cGsAVN~#|hF5)$Z@5%Zt3Go0%uAN|+kykQ8hE zxFHXi5llxg>a6CpY=`P2w5nk7Rn2wf;xdScwV;`3lwZ63niQ;y1uVKun zel2}cCi^{J)}Yjtu_qDF`%Gj6=o7zMf4BU%ysf&5iOf=rMxOZM?DVv+o8?g5f#4M( z-mu|l&X)_k_)NwATl>CLUR@17Ah8tDO8V?;HMRnZ8acjM=Rj*C`nJO*(}Us8o#LRS zpNk!Nd3oLBWz}Xxfq)ODA{Lon+EZ}ri?_|2=6+|~?j*GPts`4IzFSRh+!x6Kr*c9) zJf4oTmgTh7V#qTT#R3`F(stL^ z07pnBoA)+0RuiyrKt+RM5%6AT%XeqVGSL~uw^!*%wKKJ=%;jC5y*oIP6zrGdTP(YT zKR{z|8wm)%l;0tc5B6^&rT!lM|4RSg#E&z;hnjX*-|D$X} zJijn={C$NFI}}cT4y-N50<|J=}zh~c<+-{_DTSol+HuUrT z*MD!K&t$1gsL@6)xj!Blxg!G#yJIWUc z4Un4C<#7J*h}R?LKJpjRZpdUf&wZ@X-Tdz(JE?k@wFC%>%*cp|()!6}jHxjH*L>j6 zea`vu&Ip`Fi2Z2c??Wvl8{b;lk9JEARag1Xdw$@$5gUVzs~CB&+$i@SW5fT=yZ}~X zk6GXuHsH+Xq^Ppe5v&(Z7z)%-XP|g3c4AswTr{e)W7o=+etV4+D$?`k*Ah@R&HR4J z-~7*zDdToOY8NU|01?667}O9A9WnIcKbp3<4>Y^iNkl+QhZn1*B@zE;gkL!#N$w8K z7rf5917+VKcn8u$e|p-FxVX6SQazwN`hSVK2c$_*@7Di)RMDuIlXLu5B_K&!QYJ5u zhCmZ&a$fq9Ix-r;rK6u9SZ^R5)Ss%F&jAS(i` z6hIBVN>fRox`Clc7qUm-w;ncuKLXoDS63JD)vE$_Q}4m%-n{5y479`5D$|1x{ZDr4 z!N@;nA@?sjQy;=I64TGr#s?mWHdG!^sSa!W?0C#(V_tPE-cFJ`34Ss1bAl zETjG*4<%#shA9Zd$IZQ(z>D)%-y<>_0hA#Y0oa-`NRPa535!$2Mf)(Ge2ukjQQ6})_WaIU-?p)#Z^>=FD)ic$CYSpv^by9c#*O@UsFtF?*X zLQ>t~HueGw3dk{V7@A6U*R;h;TiaGnE?5c$D!@@gYm-kM9sTb(xVath`x}Oj`t@%5 z?XPQhvTce!{{Z>Bx*dGQUX?<7sTBE~7Nl=}`}%9^1x3sn=0`i-zH~v4dM}lM@1~2v zgkDn(kiBEKN@I-IxdhGgqt31Lf-hVf8TIf+ z^Oe&*qEJP93D=bc&-eIOeI|l{U z54z+E*~(2YD7+oEatCGF1qvzLCTcmL=HVz5asD10D(s)c4%|aW8zngtU!r4UbHqI> z-h=|3Cu0wtkXEoF(kQ#305a_~*0v-CaIyMzb_QyF7X#A<@1MU`0MN)oxuR4gZ+(BAZ|HK zz0b=MnSxoA8P)#)h(YXH+3!N2%QCAZX$8WirvY3B>jVgZ@ZWC0&jjA8_yRBi?oN7L zLE~LB9K!}qPCtWhP^uk){Q%EprMa)#FD#$mg4Z>q8Gnb07AD%5aoo z<&p^IaYq>uzt8detvntjhAIE9ZKS4JBvt{+$U~~y?>?&T`D8AowE`tmtYQYe&T9S} zBSpZ;5FQ;p{>-zyt+DydgeQH-9wc-|@!6+DcS-+mWO z(|MZYP$uI1%g`$cSV@eBpT?SMi6r!PES>BqMlSDdm|*+bdY|QK^Ag%7iPPSe@}B&q zP(*T6p}T7LHG1glznnUSR?ZIT0s8u>-rqVu2229J^LYY|NzS|Yhl*lHyaiLBm5w|EmGk+ z1$upXS_PUpQkOb4q%a|-22AUvJ}Y)r9Kc9=S@%ZTPLPV+PyaZt&|uz^F-ZKGc5IhC z{n^1WD|mY8dIE&}BDL$f*Za-D13hP;t`+xqSrGW3*FSr|iI6Z2IJTfKfWN48dx$2| z8Q(kEZRdQe>e6*M8}C4T{HXI1(^c zZY^qOGcbVoFmt_WQQ{SpkgkEI<)bq>*$&15sp9Rsit}Vwx8+|$iWs{E?SYn zBczO##`1f|nWSVn?-z6NV;rVKoGD$IXC>pU%U_RGLi|gq4yOa8hI*(r?m3|r4FkLA zzH}$DQn#>6X&I4|lh@OgJb%PbL|QQOYx`S`UWKuCqyS-t(?=~GPQzN%8FUucHb9}k z=V}Lhbk$7{$w+jPexfM=<)!Ip9f8Shq)@p>DoF(~LnXy? z?Q$*HU6NAv`7wa|!`OYRdil=v0|hlT7UbyW-L~l&wfA7d&-))`Z6<}1(r*4!Msjk6 z=-k);HHXv6eGWf?3b5AYZ|+?W152bdp?XA)*b^=}>pp3+dWRt5-?rn`3|e7_)O>br zl4qG#<(oDjoNyDoBU37c!6aV%s94|Kz3Am>Ppwb-ZSAjq15JGjEE!k`2R>6#@7uwj zrPvHCU<}N`gS$K7uF`=VtHRl5)ERmA;&9p#{td09hNu=ZFQ@3A&~jC2l4{KCNVMJA2XuV zo5@S3`vBXSHG8OIp;2!UX~0P+W<{c+pk|Y7NJ1un_MG>hY%Ez#g?-2~uvGvhqKhd# zwE_2A8yh5YYB0vh6gF-U@f7xXW1+S>TZA|zNEY@eT|n_!xJPv3^DdX|yCBr~a10Oq z=3G!hRQG2H=ZLx%3l%Gj>GJ7~T9@}r-r4!qD6z^!N!Lj8-S3S;+ssAzX#d&ckqXKHVneq8CTF)z zYes0T%}K`h)iOTU=s?~aod?*< zC?-;BEPKp4i9Pii<~rB$aJ}8P{1+f)o6kGTMo(G%i&ahRPD|OGP&ACsmO4ZLlC{311r4e?lh!mez^UuMKY{*+?8L%2aCo5-fi)wo<|-LktK;`Wq^)+Nt?I zU5!?YpF$Gv$q&C(%~>A&CftX!@n}Cg%)rzEjL7zMWFnj2O5@XOq!b$xW!ByXc8`-$ zdqAsu4HgVK(AX~p8I}hB6A$`s$3^6R?3&jyRyhz6;5`4JmQZ@E5*Hgl#?xL!~7qG&B5}g zi+=x{VfVDL^#@z(g^PZ$>6}50v6tFRe#hFtwRgVy47^p&idST(uTeSP)iUo#tByzD z-7)$u6K$jEZ;B_5z{nwJ}B>1|Z z+!|GX9jLDP3(qk|^epBL8+5>V@n=|(k^LXRx9CROdumkqu~j)a^I$&!;=u8m?i8;* zPQd>OmGEX!o1)aoHvgI6E=N%9;P&6*^c4?AWpkoDWBM6A`^C4)msK=+$dFmZjnJ4V z94q}-AhKpPn^gD-rz}o)q}Du&xXV-V1Av`6ca&~u@?$v}x6io0e+ZuAxz z8MjegnJ7mY^YBc+y*Oo&K`Eb0Hulc6w|X>CiZ`rlP4G6q)OPONzU^}Q=NIt6)&id= z_(Zv1iB@N8x=8iz&UG$iUCCK;gt)6JL9cU|q=bZV22)x$Vn<+$Mi6m}R;cVUD@!{} z7>>3qIRXdrp}~V6MVi=Z02{WnvHJ&KQS*nHw_;;j|GC|EJTvY9SBZAe`lYacF}vzI z-6%cYM~Fz$QHo!O;5#$ks@}N=Dch8=4Q?6n1%dP1+R^B)St;LocDeNCB*1aHb*+o(NaH#eRimYk8Ae5!^$nP5Yvb@wfYft>67-7>UB!g^@Y#jluyt06J7Xdy*JnX#{~$AlWzM7zPqhR>I4u!W8kUw@f%#J z3klYwlLX-uK?{swDL*&Ku|il}I3+6{k4K~q}d}&%D*Z98P>+1C6-R~_P_t42H!RkReb@H(=lLLk{Z|f?b>bUlc+Akq({DW zfku|t#QZx;9i&5567fAOi+NARC^ch2 z0?b54Endz*hZW41A?B81Cg&IOvu=*3WPblg{WcLO&@n31ZW&h|(vi(t3! z5OrM@u*bn@UjbxVtTvjCX^&w70SbCAZsM!=9Z<`TBQP_gK07mwI)v6mV|lknDn5-_ z&9!)gXd4?DX#|)gpu}O}f{}$R;=YG=>IS`KFJO-8<%PrWN0;jrpl_8IwY#J0$;9}+7w3Sr3w+eXO{ zc(9M=Kw4`2+3iy3znbU`dM1Eii*+X=#zVG>SSq>^Z z&Viz=byK&{JV!15lx}=Nkluo6HLbEo1y}Fr`1FO+E(d0;M9FpXZuE-vI$%8bawPq4 zCDClYEMw0oAsGk0N&pTsKVH^4uk==Lk#Qn9bjwhNFF`n59WKWhT)XveuFPyp^NWs9 zP{c5bI7t((TY$I(>(q~V`2~1`1dgu4UgXQzfYJf829Lc(Bk*;GNcLbDIO5^2bHv8))wo)rB>ycqqM1cKoy~3Ni*~#Px4Z!`dXNR!8q2ea&6Sns!AJ8E1WC@;ks-ms zV`T=epuWLTtyWcH*sz@m>1AY=8KkH&S=U_<3J)V$P~8Yy>lmCIt`Ns=!?r)-*Bcc~ zf?tja3zuZP?XcoC5SyTUBldqiIYCiJ-K;q%GU57qIgj7x>Hc>{GWJIGp>HVv0fP*I z{NMOV9!W;ZzqPy47adJW3xcoz{%0m2AGo@=c5EJ)6un5i{U0XKNi|}5uj(%`cH=*N zJ`K#+h+_jKJ)5b*|1yFz2|XbK}xMBf#XbV zxNQ93|3&D*O&xdRzOqrT;Rio){roxfMNL)z*?)<=@}8B&#hfly4j~~iA#NclZmBsL z4BO<-{jV!KX`J;|G&zl`|2b6}ujQP*il?g&%ZR!J2$27WtY?=F4D#XZ^WGFMxXDkH>c`gcS`L^Zf3@oiFw6VEa|#OY+=78&HL&oT{U zp?I6eDFOWA0tPA$872hK$&>lD&)}C3K;7QI8D|TTuKRq09{ld5_89U?XoM8<_3#gd zIH<4d9yrVUPwP#HDDmBLh%^gmy9(-)t|fqoQt}c*UeN&B^Q%*Rf66N<**iJ3Qe?}w^}8%_ z&umHG2^HtQXJ5dn!JtECWL|xgbd8{;r3LsDfd0rIuzMwlR{LL*fjr=31b$6@ptalC zl_9HB-$oHa_^tq6xUdFRL<{JRe?^?01K`C43G#))F1dQ({sM?^7q~Wx*VG^0;v<77 zc{V-ZNJzhDENa0+n4S7|_icM9NxyrRXcI0C+f2=bNba)unB?u8mWCS>~rkQo#31e5{O zto#zJV3@=%hiN|^!-%$genwUYfqYYnXOb@yIEw_@m;2>+?_~zI<7NRsxC6OQ+YS0Y zHk9^l86k0bmk(w8)Aufu64%uyLK-CSt`!M7+Bs_D4z0~F#k0Lz2 zD^#CV^x>#$6JvtuAwk>Gfmbfi#ap$ur8F8ndfX*RmcUM9!|L#8dbelVgXlQzCgj^~ zvt39Wob4IG{hg6h&g7hSNL=)`sI079A*($#G%LpvLDHii4Fz!hUL>a#9<`vS z1kz*-00BaFQ%}$o0NfZgx!LsovL;lQD@f&+`qP@t)!Eqz81{jpHQ+e?(Xd3uSp!-j z&p#3~wro_U>6B=oHyFe{9<#70MCySQ4Ty-edT_P(03ZWmdE={h8lOdz#}3A_>>T(+ zF0E?-x>0KMmjT}3X~5`l!2YlzXy9-zU^Gf(Q3qkO>Eiq(%a^4l98jbi60a_)P0*e( zn_OEPVSVu{HgL)xpi*Ji)ywubpcFiMTG7_OI*?Ur(g^ra(3fRT5Zw&?>w2c%ohqyd zjvW8PwDcZXa>@OJNwfKVah?7EfEu_ZciT>W0JWB9@q^(u;N36waxVP9qX1Fl=nJi* z1R%364X;QQfJqI3hyrd_?&bn897ynwuv3r)wF?PSI+6F_o{p^ty7`3b;@-WxHu5DHzh%CrJc zS=_N6H;^gn3iCZ$AMS8T!up_P^mbgqcQF%9ZW~Yxj2+$%c$!Phtq{wQB}%@krIo*s z3y>ptELZO>KptIoM6@KN#a?gpIlwSQT_GaLl=Rho7P^sk%Cb3GX_kL4&bhffw=I`y z79in?774)G=gEXX`3Y3Vy;?ek3ly7yv7{&w_;uDY9NWA>{}Zi%b?(fi755KtfkIs* z7aqn+n76q^gEcwtMzurLNwUgmXtoNpCHg1NKU~T+MU2cmJ!Z8~&rIMBE!8K0c^> zNKEGR({I3CSn?an+XG6FeLdG(}fT*Y^ZeCMPGXnbmWxK+pjK zM$=^j5VT^Q@e%!tiT$0MuiSt$63Ib#)-y?A)33KxN|FcsDcl!K*Tko=aSCI3Z-J0w z81qMgN6_}nwxnoW%^QW`r~j!J;Kf zAbMYyeW(rEM#m-Gw&mR`L)n2213EKmy+Hu2_B(djVc)5~!(}*z((dp!1!#9iN8K!1 zw_mNi2so$H(UNK)^Lbc+dG7B8QacdEWtxp%0y$!1&UaT(?~zT6VJKt3pJz9e_~d<| zW4f3A9oaKO`hJ<-=XfA4D7k9$>o9V)@3E~Cpnxj2J5^P%$HR&RYQ*Fn(0G(MeF?Z> z54QL#vdpi<7|qDlPm^pewFF)S?B(gw>Wo&m^VXh>1@GURti}S#tTtz7y1tax_Fut& z`BO#T${NXXsnhHFc_h1$>~G-QPrj$n`95GPnj>IhH}!W6s0Trxr5)()6vdh%FF+}DU?fNUsq)E?W4y9ed0Yf?c zTzCf00PW;@So903$iAGX^KDz@{OV@D??Jex(N-*^77%GKJu7v43FMdbf=0zC6-8_` z_<}8~SW0|4Xp%#w=x(C-#|^DT0aaBh_r<{-(G)9%N8M2?EiV4Smz=Eot?M-*ai%Zk zx*p-EkuJ+f@q`~XuNI44s)Zyky{fpJxj_;{ET`89XaLMKOqm#K^Ib19r~#c^9N^Z2Bk_fRTF)-tGX-)GB^0x z={?6mYlHvkudXbkNZn;%gWU$>1PrI4Wb}Ao1INUAt8yOv?&aCCeI~AP^CytqaK1XP zi#Kz?{Mdf04F9;+~!79d1Q2A&478#*GHe@3?lepGNsLK8zVTyh_!uXy-iFOYOr~Z zPqw&H&&>a_7fNW&f$h3hLjG>mJ$}pe>Fy>$(2ApJ0F1w5zp3k)eOjA7J@z+j|MUH( z{|vCL7Vmdi1OYt>Z8%f{y52SL9{!6VcQY@vwM6q|i+k=~Iv;j-9NW)4Kx#?10o&-n zq&x-$w@sbm= z!At^Q=^SAC{8ep@*f%B;Qp-!U6wW~MYXh$r`}AD@!qi%6r}ENw>M!sSSTxx6{Wj4h zwabUwkOuKHqXIKGAaI5U7_OfvzUUg&e3~`9aSd{x@|DtuJd7-J{-!kbBW`jvQJ;NO z&!4eu3VW2s5VU`KI>!rpaE zv9(_1t%@83jR^3c*72FKcC-lh0aCsdn7=*H$1N~v#jUpv$7+Ma>jYMa06${{I1NNV zbWzM|e+Fw5H6M=Q9guP|QgCD>A`VUA3GKx1oHNRD^A^vcAa6OHcGprc~7(N?GWGp=XWdh(D|cOGB1Q!KZ_k6r1j?>q%qj-80;ZXH0a8s#l!B+3RGfBMrq`4@E+m3D z1>SQ{91aSC&|ARifeZ?(We1)>B<9Vr^ z6wqke;Pi2R-t4UnUhG!J`t#Lg3Uzho(5!*fQ8Abg8ZtEa3ua=OuWby?F05GZu5t@D z^EFtESGnu@^sZ|TRWHRgAV_th4xb;@@)&UJ<}P*HPLvJD1JPC7mT|)~%KfI>S3UQX z#*2KuyVUZA9N0`CRIE4r0;J0}WeALgUc&1MsqjJ^C@Qu@+Et$Un}YmqeKaYS4pFkr zg25}{x>D7k$HBr1Z{096FHC9ufZ7b>ARwA0MRSR&eK=mLq^KvYa9W?yi=z8X>+I58 z=#WQ;Ml5DEgNq{(rQ;h#{wszUzM-T)fYl8IjRL@z#6Bwm49@46c+jHEj9qi^J6=f{ z;Gw2tTbRPyrZmClGU!y;$3fNw_8s%05rFlPuyi+BDoX2uZ{Hkrh|da~K%BB?+_R>( zxMv7S*sNx?!z1w~p!S{g+=JinTY37j=6qD?>fIRl|hab16_ z)R@v%xoMxKhL7<{zRw4(*rr~=wVJ(nL=cj3?#9mTeo_81oKQE_`j|$md$c?)_O(&i ztkM0w|NWl&sMGYWSq!zJ=55WTwymPt4okaZ(yPm$J&)?yTZ-{{@TI)*d=_Gfp8H%d^ z4hdA-Q-0xGA|&tz!G8SA00Jqfoc_Y>_9 zk*&+akpaQ!j^aq+0B-=#(oyE8YdHJWjP+b5i*O|I{}{xqye$*T2K{&Gvo~!c1on1W zD2NFZ?=}{sXt`gYQGoFh@g7r{m7C_>^|YDYbBzHWmBor27AW3zmhDv?o$GReR(j;A z2uzXjZZnVdWg|5l_d0Rh`^l?Yt)#g8%RbxB<68LZ_obrh!$TLRS+w6no{^Tl8hh=! zM2N+=%i<=CPh(?`v0?J&bA46SXwR;=tZ_L$_K>_$I@eHjR`BDKnQn@bbwb9BU#}Ge zr}|eIFl*CnN%{QfnxOjI!T#Lkw|=Z83+${Bs>h?fO0TM=#Rlp^oCoGNEiS#rcC)^7 zsZ_ECXC{g>QTw{df}J_^>}$-Wx%@Hlt% zc;TvfpQz({!$$U8^R+q(X+HcwqxQeod`aSCppiRg43w zqVZofR3oiTF*KvB=Nr^@nkMJMt9)=?e8~Mt+bW5@rB&r?!Bvx! zV5DTrbRqXnCDMb!V^8SsofFb>Wib*>FRL$~iGDLCMGMMl3ChLU(^_4op8dAr3w-bE zm{y=^Tfs2Sb4_C~G~>+bHp4OB%VTd$qs+P)v$CET6{=rVRu8u*0$KGA&V$xBcv}Uh z_tQf~7;Hi(`UHT6$$L|QfZ`U3&d%eV>`ul$JMV|{U98_YyCrP=uQIDGUQWTgX1ODO z9_wU87Z7^jsz-s?%*7<7=nKo3Fmpmuo=?Sfj%S^R-tOWwN zVFRmwkG$Kb-JX23em7asJ{8tzl%J0a-&!GZR@YoDzE(?mHbIN=zL!MsvtxyUv(ELb zQw=_WyBGG+t9OU%XD(JG@ea$I32V)o7L;m^@EYAADlUicN2d0{=RUD)iG9{Ml+b#h z|Ix3-#nuNrqRC9G8NOfk3373%=wXbZK@8@R=TE5 zo~?Zz3oN10`seP|U&XSD9P6p=Gn0blKGigo=ZFJ;I$j1f+4iL*19s7X0ndaslg7jg z5sw2tO|#7$eVbY$J>S+-TikKQ`1|okrJoqnLi{sQ?+#{IoD2V83I%`Ts#{(%sGCVz z|DHP=%dFTv#nlp_Ni~*9z0Tepby#sU1C$Mi>x_y-42yq{fBxbU8g%{=kxtLjH`~~1 z*A<+8wG=a0{!+{Wzn7YMR`ql*(L82Et7}uv5Ovhho4l*U(etqH$sy;ZzyxjfTfZ*9 zP)^awtTa3soVtzUg_8?Dl|@Z4jv$0-Ks0bxF(*7_tDw91(m23n4ws4B!2_rsCgMga z6;INv#D0LSoJ+jr&aW!D&!`YW|mH_iD2BwY>B1Vde)yW@dQf z!*d3<2K9=_yVn*k+-TagZ-zyER`1iwqI8n&=iWQCX9a6SV(qdHFPP4XzS>6HSn*5z z@cg&&OND6xjgBUpic|YvUX^<~qOiUe%QE?D(IyV|b$8GD>iLGCDr-@WCCLgF_3n*a zWLJxdO)Ugyf|5Omu?Fw~W6|EtmRou=v^Vt9Nc?I^H`NNDirdT~qZ{`Tc|6+%wbcPd zLE$${)2SN~yVY+u4qf@UcLk@k63=(Lu!k&ktNd4^iahBKsXt<6&G9EG^Wo{-Tu4V8*S@3l|l}OZ^(9@ zH$TzbH?pfpf_|@}^0LiPJ+IkMjLU)jZ;ck2D8$4ZrwnYIuS&M>?{FFxd|@>}vnRb< z5_Ca#3yvc7r++as2W33S%c=H0w@e@LI^J`w>aRq3o9{Gi_H{?4`dKUy{&n%Oug5U0 z?5{w zQdT%5JPO=kd!wR0c!>bqc@KQ8h*}}&&ac3q zwhxnMV<*hZ;1l5~OfTK)8#r)AZ^;m2w{{d-6+#JryvKB2{9(aRF;G*~=NCmrReX_y z-!oHEA3?~YRTdKy%^cJpZuCa%MzbZ5yT{vwkWTXh8zn{12B+);cBfOTV`)Y;$6B85 zP2iD#c}CjLC${h!-W1(z5Xkim_hbIK(2qw4=>5>FcF$G27d}qu`hnhc#tyyT>~dHN z4R=(ggJ4uaVmbX-Qel|>+1gftK7HRqCiLXnh~J=GYb`>v(xob zU)=VZul$1reCa)Cw`|_XWoZ3qO_^1hbim8-=Gx3xoRI|hKgu6CAi8&-^ynG&k(ItX zv~{a~T6*tP<<@7%FEh|4{*W>gqgwAi!gu)a`Qm0DPr8}(L`t8FIe@sqJ z@aLr%%01K7$Lr30`t(VltwS}7zkPnU(!YMpL;M&YkRn-GS-1fI8`qB+Ghy61bzT(0mI{y?95pJ;`_ zrQk(x_VYiZj(@G(f$hx8k^-_fNVdg$$)Nx}Kx^h`ok^z}SB;bo9> zPj0b09}YCHtIJDU`mU?Dw~n47RcyLpd;g_q4Y+aukUuv1S8`$7&KH6_eaiKJv9Op% zpVr=|qQWrRYj@_&tv_#$Vo%hEk1}ib+3nVjd!u-tUhL0*J(8o=^(^D@tSXyll%PD3 z7*J503O{&{E>7mI+;rE>7Vv41FZ@v+?FpOMl`h8GYrFZ+U+bDvb=^Sq#^TSv;CXsX z+!i5^77zD-iMPDqJsjHh>gCwco$UCtXRrRUp}T6sK8`pVPP@MAcphaG@>hsWjr%v+)X0is{&;4jYs_1Dza9nU|mcie6Q6=@d^pOX%rF z5meGL^z3mBcNEbC#*544Bx+_?kJaob;ig|S`U#y^WeTOVeX{YaY-d9wI@ zQd3wsqFelLQ-ytq!)ihd$<^wxqcKX5r8HRA|E|BM$WeFB=g}pylng>_X%ElH=`-F} zkY}4a$Q>bg=#V|=Eb;c(kk5NH@g7Q$G=Vu%WvS zt0~?xowfL?h8N)r4!8H98pEtjRwL?TmJ!b{}cJ`dY)~s6^jm zMlcSZJMkBIL^U~*`*$T5ip^je_f(u?lqPFmfMrhAs z@-_}vOEQ;{^_6)WkK5MhVszM0IXuP*Xt^sb2p-2BZBhi-cW5AodPl>9y-_f@t8f@H z#T1r$K$jTHdJ!?1UsdPAFEcoX$hc}hs0LM)@)0?Qi`8;;ao<0geJx1kWQ#K=J@nbk z;HRyDgJbASxHj2*JL2L(5N-_}6Uq#%sTfgEgV=QG+@gC#p8N^PRRgJJ)S4FesF_5! zw(30Sg>UkTc(QvSMlWjX7m2)QMT#dhq};GpOWPSNd5)+&v2MC})3}Qy8x$xg3hWt0 zhNFMa*VlX*epGBSF3lldwn89y@oGli*-nZ@XAFeu>sgs=CUgVW*9pIL|6Erm0yZZh z<|JT^G3+qJaA`bM_;}mS`4{@72ALPshgBm$z!1Yk*WOz3uIi8H*)D3Gjg1VBXL)tT z3uLW&f@X!fFf24gS*Rfw>@Ih8@KkOs_0!muMbmxj5SbA|i#bBA!Y(Qkgmp}z*ZEs> zE!X*+vhDO~!=(}4X=+Cr)rKBqw~S$GkaC-R^#s*!0TZI4#{wO+WVXzKYoOFLe-%LOR!FD?eMr*r%R8Nd_~DRRtN$Pxhe)^S~A@CL>gl z?s-81_~TC42+FKFUaT3w@Fj*sh|aG_&Q0BZI=m{&s%I>_pWW;MCIlWXb@rs0>m|>2 z>3%@J<*xXmlJY^e$T84xw?O-lqXu5NIUR`~Nv*0>7DPc|+H74PF_+utVy7lMaDHHw zsj{rB{sn}^%7X8jKe87ouFGU$b5KfKO=7*I1?iE`3g7ORNNqc|rNNlwPbkI&$NPLn z=bl)|a#_{8O4&MG``n4(Ayo~lYYw8~OJAuy%EZ@i!U`)pRvKHbnXtdVoyV^BS|`jD z*$OAN9ok{Tzh(N>CGlkG?CDC$(VL^CZ6{71l-j^%by&y{9;QfT$(lRwL@E>yt~uYLMn#vDA-l6FHg z-fq9ILS*jx;2ZE<#n^ATgAC zacv=9RG;A4-!GJ82HSHp_eKYla=>;9pO_LKbB%m$`9^Gkl6A~etki6a=8cI#x7sB* z8dvbbW_2!T#5!*N38?5=w9l=&?R@Gbx4zBtX8vucx@r7q*YB=JItGL?5Gha zpRj+uW1Y|2FUNOA;oAiY=JpAJ;yC)g=kM$}^1*M$-0{GWgn({d+xNnOpBPTa_C@V+ zeV(kB-#X(q@sUbUzAC(fow&vu-D<&~f-pu`PXihe08_*)!TFM}$1bXGevHj=m%~7~LfJpcYs4 zs)Hu=CBFj;Agl1(852>hWUtnLTwXpQPch|jPikg zPBYg#BX85;G!8!}o@LhkmyW!ZZERg&8I@ zxBDJ3lHDSKv;O%9X0zY-FQF^s85Gfa#w_=Mw&Benlr}=pG|Q+3y*-P^NwGU}4ehdw zrhoYBdV7wBXLv(ugAqN>arq}N7M9HNU!dBd(K7V*CxY?Rg`cvD*Di2bw#leB8_B;c zd#t`CSVyG8vy3)Ihboq8%T5EYa_D2^dMB|x;#1YaJxcjRo@hVjdIEo8+&4dg3#ttP z%ety^=>+wMuR0Y-KihS7NRnWUQs?4xqS?5y+Tg`$v7fLtLtGd(l}oI%vC83Ch(m9m z?YOVzxgm`vr0SD$^5taJnx;DiJWy}Uyk7k+FRgJEk&rsC=TV1R)(*3L{-WbC_bOZR z#<9VU3B}YHTLOeb_#~(v`|Od?D?dx+P#rj`j|)Ai7l7nEyEIU;5eB zCj^ys&JTVyP05epR^Mp!y74mci+DJqHoLmGkjRA_mF(^8Cpq33eA?(bHnL#Cw6pnV zC7PWC1baIMJ{BL03BKEV*mRsob#RIhM?a^UfjUW^J=gT^EZe{#A-mu90Q~uQH9p(I zt0cP!r3MNibjtvad}%-H>z?Ma-UZ~cn)%tY|!-ythF=9F?JVKAqJGip(UtAoPM*!602)A62!>w?!wBBo zOb*`Gzm0f$H*MNfqZOT*nfbNWMNUo*b;o2Gc#Uc4FDTSgbO#aQijd~Fe)rH!6)ewv z$0iq4i+N)Ip!RcVyh@MWnuTTOQVjS}Y}-?U7@M2st9erAAna zqN|gdHka(Ob^t-`7nhU_ybK%hy<#3j|fX>cBI*qbPrp|(o2d@ zGj1r9iRu!Nu#xh7nzZ;qC*Yn#c}N2igdv8%MtL5SldODr=xFTff=^_yt(rl6h&21(@%2zG)?!RMQCCL zIQkJp?_YuMc-@BLh}RTUAP$GmpS#H| zCN#Q|aa?IQ*Ux5|f>Y8edulOetG?|H87o;aXtVN^d`RLKRT5P^$k{&$O4SCEmp6~egOreF3Fv-VqHwhEWDeI}@ zg*XN_HuUHM{4z2tF@HYM;-Vt)`Kn!*Z*O7eCiEcx0i&qHU~~W;=^+5IP~q*7y<%zk zcIr7!xB@IAO{ZL|9*-Cc$|E_oDTZ`ZcKxNZg^`c?qT4(Bri6n+0fn`6M)3L$x$nlT z%|zkuzFX9jfp=;k(z{g%LXjr4lhZ&lT`|%nB~Az5T~9HnQ0NbhKIg^bCr(70IN<5I zk6gu?D&0!di}D-yv>dR0P`S-N`RiVy?+TY-Ny*%);_{u9aVPPP%cvu)K7IBnW zEd(gN*ePuA@Fc6qwI_!jwiiOXu$GFp?d|2y7zr^XCCN}nRH}SNF@Su1A6!@Z$Xt*2{9MO zXy{=CwbKTmHabLk9Fn{{-Z;x4D@<^D-FL~G7_E7?e0wRD!sm8kGnA@MkeHO~>?F~7 zB%vI)WT=1TA8~+41Pr4y)YdSTg-F<$V0T_CdHZ&M^d@RHxLg8#qBSbFF*76aexDs~ z2hqCxg|zIEJa?51!WdP!)X-xQ1b8ivvIAOWyu@U4!6E2rG?3d6&4MDQ!76h3(>mwW zqeqWw|H9gW-`^Ogfc7sm9j)VJLK=Lu7qP|40lhUNWMF|rwcVFV%mU@ zzUz#=T0KxebM*KsYPOlAE-%e%C8q)K7yP@)8epTrBXxa9!?UF0CN!;NkbZ&2a5$RF z9lFr#XrTdgYt0aaFUXt1$*(Lxj0oFW}Dv4p40>zuQ!jV>o6A7x;#DF2?OX2mq@1G&m_p;~<)r_f;_f>pT zJa}e${4+TzV7*{C>&gC3{$ z4=m+K^Q1_oUw7$bNF<}G}e3ARnE$zFZR+YArf5rV&ifh*d{CMd5OW_cNh^$U6BOKmHOsU_AA>0+LtJb>bYjpF zZ2Ve&O#Du*C%;zmy7lYD>AHr82|!raj(vmOWzvpHSkbPCwer#^XBV{)16`1r&#EPfvEv4om95ZHLUt2u5YJix%QV)#^}P6_YkyNTIH2;$Pdo}S2!a&E7-XQ3ME>8;Ky={(xTJtGF8 z=sMO)i|lF-)!66dE_fVf;^s0}D%Ksf7&@x@*l zu^(ktjUH$0%oeu`oL6>3w-Z6#Hlr1DN#e@YtFNIKd-!l8_imhNs`)jpI+ioyH98jg4H&Kg8WC$liEf(#14ZgxL?`gyZO*-EfyWmtK@=r^g^n3F z@wf$xMKsGlc#1G^1&(23VJ1D<;0*N3&zxtxIo0s7^o7|p)~YQBgdnT;Ob3qu(OJsiCf$J_j9K1RiBUnP^XZmeFx2=9E2t6S;G>((>Y)<(3 zZg}J?)o3?udN)q4L(x1Nj|ax<_`wn4Ja6o-OCQ%vPXP}T)X6yb=5wGS5ZeC016NUJ z|1~wg{Mc9Zdi4vv(z*>BOiXVM))qyb0ilwHclvDb@LlljRahwlx@p*T*?0sbW8%)* zWrFiLL14d(t=@NX8zAjhNIXX61tyrFeg#iq&W@>|pn%+P5DuBuWt+Ks!fTvMgVj;( z0f*%PQG>G(P{$nJ$lOytV)#RKQmdY*S5pe&{EWv`n6TC3-na-~d zx^X1LZ^p1Zj2dfT@Dnoz0%TO#XYi0X*{n8DQDnWgC|J05=LK4~*#45|yUC3Lelz-q zeTDS(j=E>39sgDY`!DehU~QQasU->BSX^qmhalo3%*AA&M>~;j(x71cP42^3P^ zBBICIMlB>H)G!CdaG~@OoE&~n&U;1e4q9JeJLq#k{R9QVl$kRFj&4UvmUID^@F|11sGxgC|P)>*Y!ps~BUxzdav6W|6eNfpoXjP>3B40bWmt z5!3}`V)ofrcPrXQciaBSO3CpklXc69SW0djwMqro^X}TX*`(he99R8zo#^EIt@aCn zqkZ%C>g122eUwK{F6KFqOSK3&B6O9~opmhITV~4gEo;>+Vd+t-bGacS{sY+--$MSP zq7YZWJzDV|{o5ERN>&Jt>@d*Hw0q1f<} zVzX7jmuC_(8STM0z{b(qlOSgI?`y@F$=~@;TIOlR(WG%{Y3UbErV!>M5)07;jid6! z7ng^_i);z(X(SS&&6}B0c;K8^87w*sBnJmx-rzIze@hr`5`YfYQ(&%I1OP>(H~VP( zFJh3g07zKqcU@;^Ydw|*AFer@x{Di_6hjUlz$b6A|MZp@Q->%{xwz!Dw*yP0p?H>` zs9)Ie`DlAzO8e#c%wLTC4lpDW5G?TZox0^q zMC{n`ni9NVb%1mT80k-E-o$%W(-K?I;OpT{dydv{FmKk>vbAOOV4g+ z!6PbpXRZ99yTAX<=M{m(omV9fjWuDWFpM)F7m;;WX>xQFz40{ivFGfCfqxPKi?0uZV!q%8I5D;Gb zaSjRIu^~KAPpMMm+u@2O3>k*d$=B=`7!ndBCa_h zSoIGU@D%G9h#F4T;H(Z{S%++JOIkA4!pRz)$}_QMqFCziuz*0&tJ7k#L0H(V0Oo2< zLHSpkBJ3l~qV2cgiYfam^Gdefu`3}W*S$J$UzRR}EeNy?wd^+;f5{6w#|rj@iTb}{ zuP)?YZ^zxx$@khxC^lbg8Bt{b9VTQFZyeqy?90xS1;XJ zPh(PwGpF`PZ`JEUyc6uEbn7UvQgl1^9~sq8YQORIE)|`LSlk;&M`!E0B9gLWomhVO zu3S;J=PHh~SOs;J_!3Uo+Lm$8hWF;Uq?82D!7zc4Xon5)^yZa`O(0e6h zWU`7De$@ajti-{+MnOXTF?cssD-Ty+jgKF(Tj>2?Z8Bcu3uKfYNQu`KY(`V=43Y#q z;c1(CU6RVgc!-GWaxRNko&hoxUF%O!W4~z}^}D&a%A;odg&(4@8W+S{Xq@|Q(}KDW z5DiyTjYyeRm}1(}R?m;N(_+rUrBeNr>YgNchx)Fs`9QBZ72|i3HqUeeyq@N{h_!tR z#c91~CB>1YF!*|ay=LDIX{WRf*U6&oe&%~}Y({xg&cBO*cm_38jXxN26K$`-l;2!9 z9nk#0;o98Kts46(?s9^~#NR}u&xdJC*kBvV37dp%bAfBF@J{|W zs_J4!)tqKO!5ElY@QGHRnN2gd`1jta^zFWGiMrc*RGs_4FB*I396hoCv zr=e)St5~{2Zx_hT95Ibw)4rb;cK+i3yAlUOvhkr(0S3Fc+d(zcKfhgleekE^!u=^< zZ1D?TOvA&&rgLE}RwmA1ahoE7W0KWR(4^cK7zsNR1S2j#6&7;vCYm%u-@gVEK3G%z z``OX@c2hp^3M&5c<&T6~T0@a96kc6){~?2s$7;m=s&C&^OW|{`-s-HeG=yXa@%wCZ z`|WuX?#}TEfQr^QIkY1;zL7zo$*s!g?TVMUdKkHGifRJsl@;r09(hHmlbg!4e|2?r z?Vzgb)T2>Z7kT$LNnn-ODB`+Zduvi++*3jM54##Wt26@FH@Z=hM2o(x)Z6vf}jnW>_KzHG32fvyHO#Dx>vIU22w} zn)Qz<#KelZJ}|?i?>h!}{F|Sx^5qdw@x3?pK38;DyYOAtW1oG*QxeE}WT%0{hj0eD z-jC=}L!3z(qm&0{&7E!}6Z1`>&Zj8oiH5tLy}j3OU_#m?m{B zm&TVc#pCIdFcKx^h+)+=#Pyk1zOf8HrcE^goq{s-{A`}Dg$+HI{io$P)VLP9MXfYu zVYfx>$y?S;c~>wHa4$s%$_;+QazwV3U-`7t``5?koyq6_>D9|)-=rc>?E*v%RRJ)l z&$Pn9Viw7V;;PSsad6(Q!$+wGmM3E%7#iDcQ*!pR{>ut1rCa?-#2F_ zK(CSKFp2E>W;Nxl$v;1Z9s{6`{g7$ZbCU6EjFN$Y0n~a5Gx&sL0Hd`kqx5H5ZwRoD z__IPs=0!6-wS8+ zq01 z>=l^Jsb$%Q(ea1h!MJv=JXHF7^2DtFkm{Di$RD1&phLN``0{#8tapd7*zflV6{rZN z`zyGD%EU$t)hs8Fa^~RZhz@hO<2*&5$;3Q~R%`{YLwNima|z{;y_^;FcNL+a^(i_e`6gdjfT+m{>wg+%ol z4r!-@JUryG*N%=3@qTk!lg*_wabqwZ`W!$~T)$ks5A-J1S>xg6dki7#+(PE5O zP_m1mHJ`^b0(j%!vmt^{QsW)5m*Sp7>ci@kFa&nw!~ca2{m(5C)_7c4r7*(S4W#gu z6S!xp=?i2lW~JJKeqPZDdKwGiKe>Vo)qKP5t8mulJKwaqyIJruI<`BT&R!^b@2@~F zmY%#pxi=59J$hr)IQ6UiVJgwBXw2YKk83?}bGe$DujhP@ZCp&Et=xEU+Odzv@wCfD ztOVure*!lc9KJZ)1b4d@UnDQibYPz|{uy7<^&HI6JX)vDn6&=Tn|DT|X-5wp6*Pge zs$xU=uC&Ne`-ln_Hnybt&uK4?&p(_C_RvRk=ZZ-&ce+jb0mkzqP=cV5bBd$81L}mt zT_c3X_7;0mJ_n5iPhs^fm0RIK!L>~t?@b~I%=40XfO)A#mD61W-|S6p;ch+rUiySJ zL|prCeoJQ*(rd$&!5~~(Z5z#adzij2aHX>@O}>1pE(jC1z+|n^{1CQcTP73=^Sh2?u^is8EkEmP|MaYWWXkrZ!Rag7$JI-VrJV%RM(NyiH$Q%xdd}9?7VRaw>r7451_z8* ze`8cNe2(&h`;e>g3f)<_x+o~bL`+Y8U-q6|Sx!F0&fb`tFdyUcrMAFPzCKC$;A?L` zJG`?N;Nln#aZdvQVvZ~#go7eeJx=*{%`87#&dw`(w=zeKyuX#1y2z~h721FOQj}2D zFcEveM(og{)APkDSzArlPmxz;+but+Wj{1b61Q`#PnZ#it`^b;g+fo& za8UFoAQz&*`(TFW@2-=>zE#?||6QNq<9O$$qG31o+=-J9a?XqoI{Y*#NkmBIbV9`g znnj0I@%TCxw&AnQ4%JcYU*p19t_Iv#lCO(rVIRM&>P?#j`Sxo1` zY5=s#aEIRkaB0vk5*^zRJw3h1welTBJi#%0SHNAu_%w$>k*Fykp2&v62H(SXu;v-U z5*)E0&`2+#MF3CF4voTy#d<-NiOi!(!M?oP zb~37!ilvAr?dChAx%TJOgpq2_gwJ!fd~3&MwZMXLb#K1tm;Ssz2D`2F?<&aB85tXT zl6$^@65|sZgGEX85lImsf*w~_R}6^SKtFJdn^MfqOib)Rk^%w=>htkbaQcF=(OU~f z{zMPpaEw5}oyrX`fk%X*2B|-Lhp}|FCC&InX68IbcQlxFXoc_yjbi{y)gGJ$q^v(# z4Ake&-McuVB`v!?kO`+>q)Li@`}XVPla?l*^veVDUCk3mGAkL+bcono)XlTox5uUV zN&3ZGE2eGn(%X;izS6U*6D`}C7T*!{UkQ-zG>S4ZmY$3CA~>Jx3JmKhm)l7Th6 zh2;|%;#0^4C1P{n zF;CfkHcyd*s#`=|2q;xHk*lM7(R7d zO`rZUyWhFZXZ;i({ekvLe>-8G@=^zoE9mf=A<*6QfH+d2uN_KJB!ZT?OV=xZ3q6j5 zEM?mRuu{LlM?f;GicHNuR@qxFn2VrCVwZ7o5T52k=H&E2G>44ax4&VQhFItC5T0LP zGzd{$ehDDU+*5J#%I)(K)jc1Ul=joCL2A=~rxss6<{ViuwilH$sja-~m}kvc`r`X{ z5v`~mTMs!cIVM(#?}xey-|UjgHP9*gRN#Fm?ni%55~a2CihhK2?6rlDg(>XXOI{`? zmSaX_#EqLK?|=TT!+Zm*?vCLXao{pCB!KhGcL!NXI4tac>w=V>o|#xD|KD zA`}p7;62d~FEqyE_Y3V4E{aCb_srVRr5L)^~Ba`47^vb67v6Jv?ndVMS!1n)!i z*PpNd8it(wsQ=fWG>ts7u}dGjZL0RQTr+&auA$&opM;Ps@Zy35l@!LF;pO;+6HU|O zk(bxGhEL>2X0rVGGw=(%91fP!s~>0i{$=0zEDg~BzW#rIAOF{nEf2Kc3e!wk`LDph zJCS47W6bpQ?*WtnV*=JV7+qNC2KHpQKCWkQ(3o;;Z_PM)=q znO$R1Ald={r0J7jc!1WLCfH%5@NZh`x9Aj}tMwa}Rce|X4Yb)lebXp96l5t%Bo3`> zP&p{(>>3)t9gYAQF)m-CWImZB{UIEq1(19_jCIuViW<9d@kh%BEXVErIll z=Ky1%J6IN^nuT96h1P>^=fMF)jc~wOiUljsALEUo1kByI91r2OkKSY{osK-1Pi$R zW98k(EnDnvH9bD>{i+kr&e6>O8ywwbo|iJ^#(g6D_=raaeK{r0eLl9 z$hRHmv7*48U`dkhjZiCeSesA1yok5AH%nvyi4Rbv6oOot2SX#D2l_)}9p+1|@ga*# zmo8ENt%J6rx>{MX$EfR44xSC$_U-*HRVtkHn-Q^3)NPiQmayiajeM|~*@r)1*gHsF z)EWb{8OiOl@9OQHg*=AWmR!UEc>;@A)xfMTz?2Z@eSg7jO_rHOjDwZ*NheS0eu}_qBi&d!gzJ>|M!NP zaKQ0^?8Bj;>k@6Y$ZL|e#4z2yik>91?I>7AbbDA)Cg%1)SGysAYy?Bu{oID)LqbZy zQ1>PAD_8#5u>Ka}{!Br!?T42xy1$Lrr8^$3fvgQsB!!QC53q(Mu0^fF!syq=BIv`vIgw}3cpqE)TvX5 z>IPSlRDR4^UT8H z;s(ro%ugD%b@B`G-bu`1lZ zBY?2?+`1BLlY2oEN9eUB&8dRw$ZNR&Z=2r#uJRpObpG+^?+$Rd!;P#Gwmh7iAEfrp zG-|}D8e&%aM)w|u-in$WLx_+_rkjEXWK!jig$e@8DNqs=i=RGwiiFvNpkh!McwL4u z&Fv61@m-Xityh!xW=}`WT_Q!0lM&ARcUV>YuRv$Kfenkvyah8BPMGJPGGUxvAp)Fs zvczVrF|pxyq0M%G`R9Afq|nWxVal*H1=1ygUxuul51&8p=C)V{U!o*ETYuGCx)IY3 zGE(+{5R#NN0{mqQM%kP=Ao*onb-;wNkY?GDqb>!K{{=>GzBz z4?1dUg)AvBNX|fNiSrCc0R%bw@YTH^^H5zL2$VW}Hy}uIv2yo*LULlqrAtB3*NMYx z)X=hE$4|(Js`C`*;ls7_Ex_N!D?J_K(x4Me@HzP>JFKRDp3W^i$suWl2$>}r zY8C3a8fR|deii;A9St^nw+bS6h$+mk6TBI@oSd(WqCEQCH%}C+9yf9}*%nKpq147S zSMf*m0>{9(0H{S6_rZ&1)ZTL*%a(uSK|O_M7~D$g(cILO6TI>D(x9v1uNnZS+>v4S za_A>Xt(qHUN37y^xQ>^fK6(UR-x87rbQz)_pb~OK9-2rwfFTfFQy3vkxo|$$=I*13 zG&oLfcs}jdtH4FF#&8&ZGu$Bfv5y(t_p^tMaQ%(p( zyjNjiVFVX~l(WQzKx03>cmICejE3<=$Pn^JV5Gu&!>Vt=#mnp0ElQ&KBGO5Si)SY% zYbX#csfD%*m`k!bTb<_pl=28X6sMNti;_v0}+> zDdGnCrIz`16gnDE)_hvYH-xJ2lRE%Ei*-1B&QpIIq5MI{!8~?F7V8uHnmE7$JXtc# z+lNy1v@dD&AZ$}5^NJVR8-7Ehwk#{v@0X#+LQjE@$`~i!Oe#vg_WgTzjW07W|6b}` z)x3$lm@d)x#UF2rzXA=6x5SLaxQtD4H70opHK>hp!R1HAf)DZg|mYN~{ z<03C5dMzILu-!kdkjRiTn1UIjjg^#jelU*8#z`ALWcQP>6M%23V4T`uYT6G-37AIn zc>HE^YGEaWM=tIZ6Be$W5rXbyaAIz*Q9qicFIwT~ddiqw3~=_du~S4|hxcYfdl;sa zq{O3!QSHUJ*vK%5F#u1lxc<9p$45u^U%wm}`z!xG%G0X+_6qmeghb5qe1}c@f-C|1<#MK zt}Oc*Yylbl?Y`!&ZaE#F;4oyJa4Q(afjl=~L+kt|_c5A0w6Mb6#z(&u8X6_c4b)u4 zVBtA>@H}uG3?W^We^PXDbJFb8RIrp|str*v1k-ACsi>&fZ{3FvbPz|RFw$57fTQ3Y zY`1V=-o9PT<<|tLoISFNpPc1#;32ePKI!ViQDo?_p6L;-zmZ$?CX#)6>?sZkzEA{O z72(^r(fWJo2rWDfdz7HbA$u#&HEL2z@E4uHUc$(0h#| zxjzjzM}DX286JK);dC2FC+W2X1j_T&v%<977yO^`m5>?DrM(ny(^vAmSKVniicIDU z9VR0Nd7vqXF;4Ni<9^)KVN0Kj5FJ~2=7XkAG|j&Ymc~Q^LiX?lCEctOd=dXNOnVt; z=}O;A3p`?jMI$}zdzF=lOkdlP*7UNrqjNFz0BLik{cD(@S3M7*f}ogfc{fVS7E1?M z4I4@t-r;>2bWm^F#0(4<`ixh9`>C&xWxo*(`H>88-_OaB9G^q7RoLdVLJk;~GH4X_ z$@r2FYTL77cIOdDtw_I4YaSqQ!5SD32vt}-eq$l$=c&$*-?;|uT-D%VM|6egWBt}2 z)_=|AlmMcI46D`QcRh!o*L;t$b9H6D5UK-Tw#{r3Y3THUH*4LR1ZD%lsvD*+#1PG!1#W&s@MqkuwX zQ=@}hUcN(tZHQJ_tay@|qQsmXdQi-Av8imr1|`6GG_rXal%-vOa+3HV3bq2x?Bt`ZuqJqY9P22CucyRZ0o;U3G zXoz0Epp}x60>=_|#Ti#NrsPd|QroR(+E=G?WFq5kjEL2b<4L}oglDwEzY#0c!EPYM zm=?r7?2yoXJgsMR^aT;W! z$rx`-d`#h$?Xw)>)=|Q1mc-M|{{|{s@d_v3(cAYWeA~FE{ZFbVMoK6AI$d#k>Efn< zsoj4HC*Abg?G(?nE1Q(zF=j4jZ`S3xyogV3SknZJZ!AXiMzgE93=8AnYiNb;v+ku2 z{xRCv`uu8iI4__2tmWH~+|2OX?@-+oRp4Wd>WLstQY8f z;UXcur+~l{Ne0fk*mrT?5WLhmVV9m8buNWd7AiETF)VVmbIODbH7n`VIU-`M;j< z{u8o}JoW$Y^GH67o*0v~wHh%cP=NJ*JOFo=$89!UwS^tt-d=MPliBY~KKC}s{!tk3 zCai~~F?N?b$Zxh#$iVUc+_K~= zfz^RjWn^Uysjv45dRT++0zIW;?3a7Yarx%LQ}5mOqcp+7-!ql?8X544S~ignJP5-n zxB_D4MSD<PlJB$KT-kRX6=#DQTq)^=g5#~?)0e1^=xwOmkX0Ma-BTyS@H$N8EM zwLLj%H=M_JJliG5pa@{>2c{il4R3jR<)kbQ7>t-B7mQ|LhT_kwJ`N9Mm(07cGDYs#Jgc=*Q+NIPN~g>uPfCuuYKHlb3>J$0d_2>JXaTvU1BGI_Ar*X ztNsU*XWdJS{x4*O?|rOZ{vw_aO0(pB8r;2G1SKX$0i@c3Bv(3|e)y+UJ%``{<#!T7d{QU5ey>qT#=VVYL5S!EB=rz->&^8n~d5 zxT=QYqU5qYJXTS24_^7yI}@iC`AbsB$-%i;{$!t7P1Y}#)&H@;SolG9Wv|}%T!pI& zO7iN?PB+{QuE450_E-h9A1)c28EW9?<5PI_`K(39bKk72!$p6g&p-34@$B^jh$+Dc z9d-QCHXH|lxgh7s==6N@1j7Cp>maC(0SxqpKVT1*u!~=;3OvyBu9!Um8os{RSp}0g z7ZXjv$?kve1`H})8IX^v1F5q^&)SPtjSaorawFa}BUiJKALkRO#6kBUR7KLOJWer?Pi9Xl(O$m^MIVArJOzalOT!?-iWX?XDZ$O3(45GeT56S{ahD!T)zwD~G^Bf2vXMLwZ!0LRU z@Of%399BBMHgLxYn}cH9=?lMpZAL5$P!DLEA-je;DG#ht18H@FRZyY*_jkLVCTJVW zpCbYq8x7oW?zXx@hMo$jLJemHw36E(wMcLe16Tt-xd$No$mFW5NIEdfoiX^mrCVp3 zW;So!xC5Og2y!PBbO0O3&I7c7toT?i5B!7Vyq5nYhW0Ap+W4S%9@+X8O`U(eK*--y zt*|4*(WQOqX~Ltd@-ZZPk$I2lDi$oA)Wi|MB@Jpcjfl1A6hSs;Zaa zQIyAlyDe)`&svs&yV}w4#vGCVSd8XG5C+P8KmzPY4=gwl^TyG5UP({EgOXy;7u;&& z{=Er3But9YK#G8Zl9z7+_y&g?#U2kFZk9{v2mEkhJCFvT;jwGOi8c3Me_RY%y}3S` zHOGdJM0Mg*gr7*h{!a@|@zpDoAC}ii9zS{V>({T^zyd+y3_`ise+h+``p+kWL1)MU zNlP>IZDo&BtSC+?p@s1DMv4V4Pk{{-zN0p(fLMxE@BAfq?}*A(SsCMd{>NzY#Rl`w zq|`pQ=G3U{ke-Krp~eKzJu@ExX)q(MKOe= zG@Cc$3}Zf~pS}14m?16E>3BVV_Uwa=7zO9&0sa7vgf$dY-Aw<|@pL)-6FD~d4?)>O z8iJKVSmFjaR)3KyMR9lwUBX(5-)@lX!=nA?O6@nKQTpvxC9{Gg0;q!;6bQfzfs;z;rcqEuCs2gI z>|RHoOJaOfBEJH9`eeb4<4naPmrIi4AVdEv(Sg| z2&XNy$wZKU`SN8skImu`7Xm?SxP*(k$@ajnf^4oGV-G&2H+e^(HfF^z>0o4Jgdx8* z!z>pB8976<)F#OVQulln0&gS8a~&1cOCm=Y9>=FT+f-F$0T2f#uoP%|*Z_W9JXq_x zxAZuC5apK2%E~yd$kT0$!-vzA{KD7w;H3}l5K`db!cZ^G#q}e*KlDBR2F2ficinR< zvFp*cX2XDoD{BU$HxR4ihi(U$joR#xcLbFtMo{09k`nSJYTf)2AN+#_M4cwBSC=K$ zgJshDmlLmIl-v?!j`=5_FqzW==Fc*v5+}w_JUk|=#`Fro))}9AjSgu7E z;~XUy)KF3gm)f~Zf-k8rKJmGrU{(61kzQ+$^mNP?3Qq3iF8msNdHvJ4vi{=~Kg^DO zO1>L%o2C4T6Ien7f!?(X2UeRD>N;KxKseo8>@sr4Rwez& z6;cg;ahU(x3ICtHQSy%eciWKsSup|b$=84OgDjtr8Bw7EVAbpx8ZJ84!&doq;3+sV4I3)vHOi!2t1ba3^lQ*;me-qz(GXPVL0WlO3Kd9i9u$&1;Br15=`T zdxS-VdM*G<-5@As0L_w6hW58G1_Ev@DviQ)KZlwmfBZNcrv^+b)Nn#B69e{+CY8s%e8g= z0Gq+i#MJoqvK)<=H`gZa!vHmel*8QZE(*`zveK7yb;d3gLg#?B8?+wF=kQ5%x1LOb z@wfjRp)l^DQBC^(6Njio0ulT`UrPgOF;WG@8ph=FSxm0hkP2U;q++P%b*UIRcj^!%qa~B!s3A zOF7^-qCa{Mz!H7XP&=BASVgCS@CW#e<`H&nx|do8>F)I)GH?Y_nO%<}o;xrxv+R9~ zJ4CK2{x|C0Gc3w%+ZqLJwM}5c0D`sw2?By-#e|3?CFdXtNCwFnj3|gGl2H&O76K|c ziAsYCl*9r_f+8qcBuW0pqPx#NefD?GzV|-+CVuSD+C|k{Yt1mmm}8=MX;^!36eSW6 zEZwDxS~Pad!tB(_0J!`N^dlk^S$qh29GS7)mXQEE-G0n00%Fq+ZG1aahsK1Y-EuQW z&hA;jLS(xIe&vVjBbbgNOKHrosS3b?AR-WI`v`}3r*lNDHWq4Vr{E)@3gJa)V~#Kv zA+!KWHxxt1Bvr3+1o&-4zMl6yHB?I&Sj|v|(8z zOzR75?idpFurXcz z{Q0TXS3Yx{W$RAWK_@oe)r9Tm>R4WSSyuBxx zGX@jQ5C-59=Uui@yW6OqUFSi&;kfMOo3h8LS{Lg2%His~X&R*4vHOKdEGGN7$`ljC zAwa}X364#n&0V9>ErU<+K9XY+e>5=sn0MXAH7}G;QD>^6*oD|8(3GccQrdvEnd+fA z-bX7Bb@o-)uG6Nf28!4enBPf2QOKEG=OgPzn~2mM@q5i+?8KrwFd@4tOS~m~=%7r# z!B}n0y+^ZBnQFcYls3{Nw^aVnXGP6l51R5(?>zidx-!7Uv zbB29BbBZh{fM(AVQp?5XT7j0;pGvxp3f(IIJ!9%KE_YQq?ktgC0!$HyFu>;@h~%Wm;hx7!?*7rR8_ALV-jG`tjq>&R{xMna<|OmUm78ZgE`- zm%~0UXDpQWeb5`raN6oSt`*mF{(}t#D+-gBRzw}P*$OT%kUzmsF8S@% z5$`7z6VK^Em{==4J$JnS#vIa$c&SZE7GLl>S{*Wkg!7Ot46j4Oe|*Uo(UTSSmSSNS z67`&R0BWvKV3;30X#V+@#q->faNt~dZz=q*S1FLO8dKd z1&o(ezZJ?i=zS!E+WUg*Wd7+K3lC3ECV9>DfF8rJe7-Z%kFMkdODGi?PatL}R;q4! zxVc*To-Z#woh_-BedNzvyp8zNJMZJ8>OHoHHq$4RMtO|ofq8-QQ>sd|0K{^&UVU_r z#7=Pg^Zcf+<$KxL6I)VY@bc|;7KQI&iaJ#cq;IfQQLl2vR8*8(R^vPE#O+-P{U3;q zZ|U+iB#!ni0I2%b;D+&lI&(|$0qR>-v*IF;=o1v3mz9#-YANZ7zFpLr^cH0auc8i7 zHkZ--(-y}?@+yn4yjz@G!uos|3fE#%H5%FVgkFwYcwDfzn^CfFf5FU@ZOB8DoyZlG zM^zcQThSE18~T{r>WplJvAChLpR;t6JgB40UjUA(yKUl7d?~>IzXKu@6C&0lz|V!D zGez+L?nGz{#ie7f4!*zkChbarR#}}c1k4Fbq11lfi6ZQq)E)~#W%>yXvnJ?iq-$A`|nQpe2FW3WSY(p{VTe10@nTv>$st|GClAji`5Vii3F z#y3%udf8@&^3KK?QY%WYsuq8xemzlG4$BVDCQCHz)WZYMqi6wHn;$lM{Q2SYt{}MQ zgP(WC*BzRATMV{a??XEEfHZqj6aqAcgU@d3(Ry#e?;+AdC~WK!L@Y4^X24`zJX_4{6}YQW~{ zX4#_Sv*>0ZJe|PcQva;b)_^F`QzddSkXTREa`YmXC4tyTUL9# zgv-cL*^_7wa&F}_l|@O)wQFL#Hxy@C_Z)Sbm6Rx{DKwdWm(drgIp`z2!Qw~C`uaH$ z^BA~5+EEJw+9u_+t%{~}P4ys(u^DDgE1WokA?Hm~3E7o(m6C|!4%!x9KXJ(V-?7 z7ML!xI2)H1KW{o?kp{O9;(azRW2%!sNj#f-_(EGgXA zIX*j5y=C!=q|6V)p~ESTfoD*aq~MGcX;kx;Uh-c>N4FpiB?}%93gu~7!zuC+IvWFOlW&ahmpU1UGVPdMnI7qb zMOS=%jQ;}az6f!U|Mh>kw<3S}-_oT0pL?OVhwnITKZky@Go5@A+oE_P%o=S8@mw)? z;z~N3nAeY~SZ2u2={l{6`t6J|(1#7Yz$6x0JLYqohxMxm!>}qWTkmE0{k6O3o zA#XYL)*0h&rZ~~Y$Yv5(cQbbk&l!AnvxWaSgDM^i{~0{d38@& zx!!}~1C}3Fd&T}{SZeS>)caOB+GV=DD55yL$|*z)e!3|OfKetOgW<*e2|QK^P701B z55iwUIpg7>p&UVU>Fp?4z$ZksnIv9+(V6VTx*SFULih2H3rnEAO@cjX28Qw>u|-GZ zCUW4wcl7Ss)7Z%dYpViuDA0{PPJ9-(s(UA-Fx==t36^ZGp&(bb0~hH#c!Et8vv{o2 zm=l5ZjyN=b8~uqlQtyq;lyJ#sr=NeEHmX+de4M*fmg#KXI#$J^n>U2Yfz!Qg|Gi@g zov6B|rX~bm%Y)Cu!ZHot9J3c1U(%e6Kwsxn2_l4xujVD*>n0sc>NgeW6P?jX0qZtV z<87E=pMl{p4E3)gDxwj8k0ETetH-Y{$MUFGg6eVypS+c1l}k-cG=}y`P!YHzu%e?! zj|MmgMDXUHQ0Pp~~+il$8N1bjVZ&B|S0a6E0Z6JNnju{z!DOs2s z29}77i)S`OFRiQFhJWL0Rm9_loj^Z&5`+31UrPDctXZ*U>rkveUjRGLMjnqM|dY}m6d~*Ly!FfR|5GV2bdhb3Xu=q_~MO@v|${8VFJEB zQ7bM&gC;REkm=h_%y^5tVLb)xEDL51ME{4IWD*|}6Eo*gQyEq~t*5qkHDvfgO}c3E zlRz`z*^q#T3r)5{)t|a|%~v``KQon+ED(T(5CK71K;ZoEzb-O5m+`S5?BnOZ1o#zk zISQDL5B*ZXL}W~`^~6>AhENSw4cULX$|D!L9|$={K!!>Jh-L$KjYsooCvvw+qP$E_ zF#2=Q1!i26_bN^zH%cH?UVLVY4!}ym53AA}f zw;_~c$E|`-wt-rmwm1b9@p<&dXAh73jnP--V&Zqul{_kaFd-v?ogfRC_bv56C0|!2o;mQ%BL$r!$>_l*6r}yQR95v{o zvptA)2HZ#P;3ptp?dsLQi{HP(4uH-*vLX&n{16+7*?rmBj@LS4kT!jV2l!fGU^PZL z@T=@efVu#yk6t*5xbk35FO<*V1+Ej8zW)axyGqoqr^*bl?@2|)8g$jz87yh*dJtfo zuC6Z1RqUu=H(f#70y^UlMD{iUZ8Q+mZKn+KXEmAVM1KW)u=w-eBxHenc>199Xta+) zf+wL1&7BJZ@Dqm?KsB(Lt2+CEFi5#XnelQO`2~(|M@_225vPAIpobr``iJ#9cGwVT zcAJ0F$Wpo|Xpk}Ln2J-q2z<-yz5)fH0I@6vy6ORqqBSLJgobR=KvE;#r(nnd;3t+X z0)XENLkFZEa2-Hv7@2Dfgmj>{L*5`rDI{DFeMXo;>&&8HT-^uX7&)i>HwZRB8WS%A zcAUkK3}Kpm){rA|x48(UK1ARifYpFvBbXJyP8WEW*(~BXlg`*s`wV7Sj@o^>`JB zuz+Xfv!?9mG5Vb_ktF9_LvUeoqBCLql6<{jlSl3(O4Rt%&p)JP1BDM1-RtEBS3C ze!Sg{KSG3!pU243cbJKW3mD5*h<&M!@dm7I4xZ&6A*LV~HSs_pgOE6nU)-Mte#WLP zkH0$$DKNC3Kj9ByKfVi6-bGM}9vrhy7#>0jE`{EU3x3?&G6p1H{SE{hL3$45fv|Pk zPb`s=6`Mk+L(XXihlaa>VTb0xEi?~o^-6;sVF${G&mi0iFpy){g0>SbXM_>&gX=63 z(9Rgxb0L0=$;rz8NDMGD5ji6-pY?{3MYu{f5V&M0x5oP=bVkAbKR-j~ogCy3Ml0t?+SS#n><&ws<;kUv_im^-(EC4ms&p9vcnbD*ICn*_op{e=s{jpSmu z0ZAq;Evnv33Y+*VwNN! zm5%QFQIe|3&%gSh_8D(u#JeLoAooBVNb|U^o@_D z-+=1w6@-8ReY%zioL%Clw_`B8U42_kKveeQ>IY)?)itmZotOmfWpVYj{A8p&NP%R- z7;Gtu2r}gBSUObYSfcee8b|a&2_p5)T~dAoPF;$ihpVHGK_17h|5<;kl+^jHu}w~N|n$8d)6&dl_}kVt&5Ul)Ra<~21F7~g*(agLgf3k4yk zpd19%CQ1b3{}YK5tA|p1k;Ezec2M~rB+j)zNSsa&R1$Yx$zouF7@}x}(+r^b#3j|Y z0rAh^;bBfDRz4o}C9v*If`3hZqFdDo1yVCrY_QOpEp1h;T(> zAoiqt|6>xeHOl{REoJ&+t7zX-r{A&S4fI zs|Blx%7P~mB%AZ?FX9<0L}9Am{1y(6j1ygQL?Z2Q_7z}{>dXjo=>e`NL}5upb{)7= ze++FMty$}+qa)A9^nr9Gs0rC7T7(MCcc><6AQ+`#Dw|;dDL7>mvZgu{B}POU`>^yo zXQ^6;Vs3KKNQ zF$_U?Q8S_J;|||ve*T0S1SXI9C?+Fi`{RlS6hx|&Eu$ z)-MMAiB`4&ZYtAk^drdA7$B61%x($g~Sry3Boc%Ai^v z*DufZDXmSU6rvoVyvk~1Q-4*Ia02DI9VYV9(5X?Fl$ND`R?Lp`lc`i2KrJFW>hUKl zlomN~d`)DMTU?(icU&KTnst2kE%+P8k0qh8ifof;Kj5y?oh5$IKXU7izp#M+AboV$ zoUYwKT49wQjCH+0=w9E7kG=PS+O90#R6Hv48l^vn;@K4m5mqMR0uIv+ zlk-R4vZq-mSDQ4!Vq1pZ_k_e?q$;C!Y3ZK?#czwyZXg3mP~4ipH)cTMdDj+)xCB)?qnEM&~JRiP}J^OBkbWtW<8?1@_YY89t0 zap8O5Fye8=m>byyjZ5zJ-BFLBd8gehPpvl3$Q%lYzx{X8evi`yv znx^#%pjE~*-9A(KdklrfHRPJgl~xG4;!+jvvRaqp^$Cr`7c0314Y0*i-l3e=qP8xz>Sc31yw|R zwKS;h9-i2)7Ctn&Hlg4#(^x~QMz!^08Ts`?YttUi&_4>&k&w?$U>!2`H(MWBgPvdfXdmCcU*^EvW&v5lG94OT#YFnU{mW@}y|j`59MTeF|dJb7^D)wS*x z)1fY&CSDZ{g_oba!~%n^qnikBSA1_rwcc}xD@ZTnUeMp37kkpiGN1sN8N(@ z2FD`R$2Bq2(I%bfK}oJCLs+GV(>Om9(x~Ctq=>Z3qkIWekA#-j>PC?ciuo~YW%`s0 zOg?vW#Tx?#(=IT7Px=7`vIERfFa5$sSRi1msS~mhKQTb6ahY@Io#FTr&jk#i`HWz0 zx{IXFyUMSe@db0mhs%`{gn*=vA_@`>W^oaAFr+2=7Wa5+SaIgw4v7xvx-25Cp}ktx zadv#Xg24FBjMiT`mO#kb1&!a`cM*q{`OODbgantYK(gxdV%C+)-u4z_&#U&0_Vb*w zw6uIKE%jlLOx|RgDA-HiQn=_wz3?ZkgxlJUVS6z(Ys4Ed+fJWTT&#-xmInHH41NUu_W23Sy^VSf_7~3A~XDff9*JPx*g;+Eg@5QFwEEW)E}Y zlf#Ek)7(F9*Q@ZHottYPeF7YT5>alUJvq`BK3CHSmI>bfd3@)wkOpkn_<_`{JG)R? zUM2+f{X(FLK%6e*ui{Y)0{pUQK!0JAB@!-aAJAHAfnHIoWtV-bt1;XJ&vH~Zs5Yq4 z5;HI&gc|M0xtDbIRwW9wC+1CPfrxUNsR4D*0CNTme$Xt`%1JR1w&|UCYepP*GnGBg zdd2ePlp(bTv5#J<#-mG9r6%U0Xo$_n20wM$V^>8fN|EmMEK^#RX~G(=w?c-LEsDwa zTTHn)C3N*Z8*VhSvtRHp&#U%^>n7PteJ~^gAdB*`&MA{sR>?Ra7RR--v2(2(vuk6| zboZtgU|LjwXBsdYmzK(Wl{AVcMB)Sn(=CXnn%q(9a~Yp?AF9TnTiJ}3-CAm-XRBN) z3s(~I4*Zf-sIpNl`&&x&|HdlKC>5GP4G3{RzsO$K&KCxC=3Ug&5-*;x2SzW;O*d zs*sHrJ>AZwB!00c#SzL0wIMOn_S?*K;2qc5O2?KQLOR7Ql1{V>6fvp}^9#FfKl6PL z&6;$O2F`3-`|b86w3w=eD6)=e-I7%d(d^-2O2-hm;=A~7Co=`qbOngv@SY zk3sQ3*9g%~#{Q<=Jj9M~aVPTJ6iF4o_216*Q>)x%v&>kf*f`REcv!6moKt=ghDGQu=Zajno(%GLzhEy}_%~qgO_`m9artdQzQK{` zgvsOB19TO__Qjw725%yN_IEiV`6vG`5J2SD{;R&z=TQ4~&ieOf=NC29bv@{DZIcSV z51ZZM4+BfI-=JgE;LP~QBx7$!Dd}XDJ6Azo84YAC9=JX_^F}M;bq-cCaXkGaK^z

aR1`D;Pl4W#0$2>f2Bh=ukC#y;k-2@eFKB0Jl{g{1C^%exZ&a26BCmo8lz9V2FFx-%Qt*j~Vp2nYj` zP9`9t3Q`<*6u8$VP8w*vxT*usFp ze_&x+HAqyDOWa4_4O2Y#Z`DNfww;`^(EfKjQD(J6VFz_+To_Ic8-^{wG_BjP0V8(Z zpfM<4WyKqITxVZ(l91;nI32tLb%^)Nw&P{bu!T<55&70nUIv{CIImJGWYgJ%E8z zfOy{$>^2+o18aE}W)Vv6cia9&nKWdZ%_ZL6kULh%No|$oMsvZmb*l>|av`UjfsP+H zQCS;3ZIG%op>J>|0dU$YE7t*Ig*pSLV;;@URPAOg76ihtP5xg|K@#`QF#zb3SV7|*% zy1V*`H%d%c>Z`0l2ABr!5Vi~l$)S6Lft8Z1vA=1~4Qhto(UOs9*ZSpjcQY;V}0md6Imb6Wt1ax8`_e6G3 zTwGWf+c&B}7_9*B;&Y#)y{5SgUq5z`Tye5vi4Z@4hREIpOrpH%rb4|B={_X$ z{|p90@ai(OoX%*gXZMc_YIL1D{{`&&Ru$vu5MUj~yra836umgk7|>p}mN?9y>$!vF za2*d1UOWR}X=13o-}N=#Z3vH!6JWcELihv{dK~cw!b>m~zS2898;V&#AXtELw6q*Z z3;%vf{TF=W5lo0TMqD_*9vEZ+UY{oV^7b~=c|@&4&ZrA`N6G|lo7qP)xQ`cuFRPZ+ z(&&AbSX;g1Iw6)L+lm$sl{G;qVq!W#+00-lmHI%ez9<_Z7$Yspa__LPfKnw`aKtDe$V{yl-JHb?}Pirq&X=5aXk9wDLD%-NTj==sHeQ?ji15ghnp;W`ulaTrQDbvA&D8&hq80XL?69!F=B^M2x-Eza_%kRf@aF-jf)}IoZc0KCguPa7X@Vmr^O7U5>EY%SgoFej;=Sd217jnk0Ijo zf^m_`{7fO9Bc>Zt6=P2TjT10=-LH?b6&e5nRf`o*v{-=jjzD6?3k4NtqhTSuKhiNO zAHq*eZX~OaAY(yyI1GYs9UEKpKs)H26PI5@R!P3p+YyUz7^jtyoYoVB!AAQ_28zdr zI~EY|92UfA7^8%#-H;H8s z_9f#0mC!bReCvm|y>pakS*zFu1b)DElner(7MOxC-mD5Z!+7|yV>5e(%oIW=M15SOw+k{O-Xa^kZ$2K6xWnFu%sTj>M z-P>To31$*s3drGL;&5p#2I;w(f8zQPkDdJJP~68n++sK%>F|LZeFgrO>wI3%HRw~# z8%w;2T&CX`+V351-+r5^xQXj$jon%mo2onn=OObTQ=N_*^(PlUVCF8uV;Ylz6cef& z?hl|nYB*$AM@wA~qe}xSFo1mti47lkR0e z*F}sp9w%5O`T1AP$V+}OSf9JP{+NFH8=1Czd3jlx)*_jv>p23B#TR~#bZ>rTV1OV1 z@tlkbVuI;E#;7*g{+ymZb?OwRD0TMl+J!$w2>HVOfu@DP-x(B61{o^Lr{y(EY3FY)5}iO~L2o# z_NhN<6*|51q{x5&>kmr)&i{Y(d*t0Nwr{p9+XqEolIqNw4TSZva+v~$sV@gJ^qQS( z`1XA3yVtcZW(S3z6u7pfq#lw1l#&mJ=$UTN8_=63T*gpi;>lI!QvlcZv1kF{&0av) zMXiKA*jF60ev~LdJl`VMTH;p4!02jQG)QlhmT6g2Mr*5@(_MdOdA#Ahf#_V{Au%1{ z#;xSc)5@`o5Lru{eUKthR%29q7%~WC zUmGArh|H+Zp~kadM?%#_@-1}eB{pM5_Hup!g`)0;AD(WRJt6t}Mv2Fz`3(VN@YIKe z41xX(#J#Z)CW{7va@661qe=_o))>0Vc!Wba{w>4;t2gg=LCnO(Pi@Au6_Jggt3gzS zY5K)?CZF$1=v)r`$iSg?7)#%djqGCn>xqyNHYLm!_0JPWt;PUMI8{O?5{)?)3t?0> zUombW)Pw(6C4_Jj_59hDK*+UBJMqXoA#kgUy!cGZfSBdJq>_O^hJz>ykcpx`q4<4) zVBhFCMUz^JJB2!z2hE)j3&cEFpKXWENtPe21=zs5b~%43%FO@JtdPOqA!Xo4(7puL z(avE(AplKsp9!GZpn zj!bO8mH2qjrUlT#XFv<&=idmGknh7RP8W8MC=vJLsdjz+x@GIutz^z}`*i{_kS}98 zid%zsZev?V3`V>2A`l9hc0fb`iX02*Vf_EK6BEz28xmN=3&W3k&*O35wWfQikd&eB z`D!z!6yCh~ii3$?CAQd&#Kpypq1VQ6Y3F9#2LnJ-7(Mw*zl2i{;T##W-o?T34T>=m z9`lN}(QV(2{tml}Q9}Ld+1=;ggh==ciF2n;oLEu~iPf6N8M~D03gb7`ab-Q7@yK~zra(7WPVihtrpf1)QWEnALM4wuJAi@F|d|<>2>Lm$y zm5?FN>}DVgbB#JSNOPWK@*1vn>7hfzTzm7EO8VY*$aEqzyLCi-9C3Hab%4=WCiZ)Q zp)5DvHVazg%TO|5$uGNc6cPccN}=?@u(tykj3Yoe(Ax9rzHGXk;<1rdrTb0DHbHdq+2rXNr(zTpLkxB%@R+F1X(Y8h=LPt>*kG^AAr86wJSsb zGQ^Fz?&^z@AqZ35-%sd?t+L2!BlNT=#*%K(~FtY^`u6iSj}6}`DdCA4j1`y2B{sL-J1m|+VonH#hrU_#YX;m3_q z!{&~#`?z?@3j-IZxfZcD6ptunuqV(7fEVJ3aujKK#_X{^OMAUzHt^-XlIQ#C1B{3i z1X513BjAb1o^kA1CLu_aY*#Qk;q>T{p;`}GkVx3hcPz|1hcps+{WQ~eqTZ#4D%bF& z_GDnV93ug7J^`jl%oe461+KMr)vD)jf;m-sP<2ko9olOK3I%ahP-krh{t_a2WXX*X zp=4Q+Ceb3aaOR`XHe15p3iT4s-E)Z;h?)Aj(f-GN0_p+9utwPej&j;f!oQ&hBIRTJ3J3mY|JV2 zJgTEgMRB4R;PSyPsU){|83gnOaKpr?0tN-8MmvLy3MFN(sVh>my0O6F2VBMm0a$}k zK~-1lrwgL~el;?sMN;`H=S?R#4QrQA;Z}z5H@n`gTa}jWlsa+e$3t9%-gaU%_So!$ z{<6X5i@+mLVAs}#Bt4LDZ}eOyv;wm=J+R(Z34UxAa*V7idH)5^Uf>~aN#BE_Zdnd2 zPi+SV8N^GFwlKb!ZnvGu&w&h$#xPsg#+8BEYU3MvYSsL_%&u^3Cl4amd+ZIK2}iv9 z&~ylihO><9x)yw*PdJIa#pdpNnPs)~bQls7q)M*fT3bjxXj7=%AgV4V zi*yk9i^NX2{e=baGl{w1Cr1lj~kx!7TVF3_XKJqzdFfz(S96 zUez*Q$-tm~^E)0(RNHTApmNOc-S*N*KwG!&5j{1Dz&@PvEZ*MUR-|P(ARwU3M||_F zRwXQNp+JEnlXJ)j=p1pd&Oat;g>#xXc6>%E>O4vbKRqaQo}7-u{lTztI6%87y~H`P zd^^p*AU+KBt+roxL7QZ4Ieeeg-XD2~4snz%r#!z|1x10W4;J*@GZLq0?FIY3Ko!slJb z%36#`myizjy-J~E0uY!t*Q#cCSrz%O6qWD$4HXphyFv+t3f#uae_%!^P*wb#SDi7$ z_2|g$47%Z1fLbhkZ4iZMP4aKEV=VQ-d4|#1^?TXzd6c*eMGl6gtEQ?e^!hvxK{ZBJ z)GmcTL%w{Sr!$95U8Caj3qQ7$R;hC*-;D#L=VzIvJENyCkNabO2pV9s7Sx(=Zs*o- zEaQz-cMWh&u`)FfJD)%Ff`2J69ql=Mh4~qe8oLoXwGTlSu8SB>=d+PaYMDG1$iq$U z*+5r0Zt0IovbSLl==luDEN@t*A|TbdsCc=<@3a7Ipnj$nPL3)Q<{CH=zfGzDCx;|` zIG6%S_QN0!ex+9#Y+UOV1+nFgtft}W%tJ@FU*i{8Zqs$|t~gJo47A(5OneJ*fN`SI z?#-NLRJz_>u$sM_t2OY`X*HZ?rV(K4=;=~mm&J@xXBL~Hd*R`j%`?bc+n#b1_C^{T zM$no^^%D zMeVFdsLAK&hP|D4nDP1{hT3kACL&`{z%zbpXt{XARNP_Zs{ogXjvZgpGu?Z?qj4XQ zAXR@y z!UGAVr5D`V&$Q0W36;SwPbHe#&FZD3cFO$@Q zVTOdm6`olt1Mgs@Lit&0X|-&vUktI<>EInhsLr-(oj}|j2JXsik*_WuA^kvep*8B= z>(y!gjk!pviEthZuANTIrs81@5i63r&3P+UQCihlQbmtC_609tC^oL>1#EM!EyQ-e72qtz3_?y@r zlxSv)yFZVf3K>dhwX#X9={f8o0HZ+s46&sP^M|mE3HF&qeua)ORQ(f-eZ8=D6DW#R z!XQ-A9}$mX<&ZJaHw8^L;AP{{fm=v^+**obnGg=cc;my1nl2c%xC1~8S;u)r>Ef!* z`%UxHZ`N)2()g=hgw5KOFgNFC_ewl@clmb0NI zRCQ;9a7(nf>Oj9ViD#sC76?K1#v}IIfPx|E2W__TMtSPark_a6kj}Xxha#`!k|E;7wmv}r*6nhg{)K`B+1L*+;4`C!pk8C1Rew= zJ(DURvG=IBuEg~t}O?Ks(=k131*6vfp^$W5Rh4gsI`j4n;LDML1fPw3;JkyW_5_>tj zb=~WkYuZqOHz8j`W4v)?f`375)d}v@51(<69lgbGbr-~)EUcPcu7Et5eaEerS}cc$ z>b!HusH6jA)jwt(mKG9}QaI@i^(P=+t=?F{-ujo0(8#!uzaF!u@OM19l!Lf zE!xTGAJ#hW>*$UpKDTZcpV=Z~^E>hf3Rg1&%}ec@9<~L~i#AYOSn(F~*u8+Y2!s$X z?}Bac!A$SE#mGqw=PxbrZ-3H`o9ANz1dL7-5`Gyc?^O@daa#sd5WO`1<{I^PU3b&G z)?OGb5HFwYyD^W#%S+_r|D_4SI)=qAij0G-T{M#V!=5VEr?jbwxIV*E=REYe_)`>% zf0|dF*|2$YXn42_d{iL!jDAB;C-U%x_V_Xuz@Wjw@fo^cJhsou@23;J{r_pqiA?T1 z-m_;9YFaZC-Jl^VF_r>31w}p0+&CCWE@5F&B&wTSLWzzJZviT1lxe&ex6-Cs8QhFE?$Z#yikWac8WcVF#<*j+s8jz96#RFnw zvmgC(M4JAXaFP^$^b zLiOhCl)J-)!$)S$tbN#r5tM44CMyO|<}7$L-35`ynC#%%(W8hO99bu-97lxK=1_iQ z&z>&`XJp|SrfJ<5FgAhF2pHQ8BdVeG(Ukx_r3ba@SR+y)DK`*xxL>58qHR3goom(s z3;Z&1^i_Vp0rQ30j#S`|iAa;v76rv9(_nB4(nm?`dJfQFS7qzsV4WUfGDWt7M}CbP zgdWL_TmA5bZ^<>~4X3`h$@hmtw-1_-=Yd@&d=d9{`}M!nfvO(3eO8L&CVYil-K!$G zg|c**9+|e9?*0Dcgv81}$?83nbwmTAOuaWg>+-XVx53?k*U(rsj#SlW; z<0d=33PC6#K%j7r@%!d3&+?~f7P6<^G1GS3-^|2oMpZ9tZ7YnYCRm%MIj&=G6*XgZ z@43NlrO(PjdS^iq9Pl=`!&vCET~aN%PkH8caT-7s%B;{+Xvm?tzO7UN&6e5ZP zY&r*u5m7F*|75YBceciQ9jH*HwsIX((euZ>;fx|Aq2D_))B))smVD}|jV)z7Tmv$n zs(8P35WZfIZa+p!(H076Mq8f|neuFvT>Y_wwSK^aB=;tllS2|||NMGlfpNpxw1-^E z<629d3NHDp&#J3DOA~1QsSUK+k_SXMm?);WX+%kkF<=|P8Tke zb+g)Tb!{y#^HErK5I0$^n#1k&YqHBl3;Z2;TA@(RvpX<^xWEDcPnbnyHOBEyGE{xbGE|j*4npJxYH^K39 z`;XIB%p%rL=pR~Y7@DOg)x5J0(6BRXF=c0d{F<-(OP~HzIjAad@l*K4AEz_xjHiiZ zr!$YEY;u~x+fHufiV~9KIsNI%Df9Pe-wyOA$SRNLbMAIz^8x`TJ{2=?DV8ng_N29K znc5sXi#_$gYj;_0RZEMdn>m$#mpo`})Y5}cBGPoy<-@>`>IZJ-OkM7Dv1_QNH8Qqp zcO(9B)AREQ**l(MX_Gvgbyh`Uf^@KgpZL6|{PX2yE)ijm^&K@v`+DD9w6X}S2oDaw z?&f_dpixyq(Dgj>g^CyN;T`VcRdBus&+^C2*qwVkI>K|b{ITGYV8d@7#BgjNjEj}n z7Tq8_%j?!EX{BqCW?$;@nfQMBY_4O}31GK9_d=ud!@3tmirwPZy|5UB=02fgwv2z` zi}|@-ES2L+cYwcDx|A9>g5j7U#&|&st9-k4Dxo^*t^8GXyF2#f+WdtD?Aj&x4z+UI z7DHa;?{<|g!^<^_5b=g`xi}OQr>xq;(Mts~3stPKuz}+0H(J8N(D@r`&#(x(VvRc< zSDS{e^u7H4-yc3_o6I^LaRNnx>-3F~9MwPxs;tht7c03D1jHSfQd}5B2QIYQzd4+A z^c@ESsmFWnB3N4q+B5nes(usg*|r4Fg~0B2Vt^(XKve~iWRO8ujRgR@uujOeFvtjrf0#EWiGzLx+C9K0f|RE z`y>22ChxV#au$Yt+Lr)K@ zvL~L_y8AxDzd_jL`?<<-ZWcGQm_+qr^JwaC;{9wJH(oeX)?YOW3j?a-IL5RiTQMZ7 zYSeeTz_D0W%%I4nmba7a0hr&TT1{qiN_yCTRZ6gOrOt*@-iNjx<$DewGnhZXm&g7p zstcP3Ga1P)=kPfSS(*plaqOt=$u=<8l-Aa6+SaTrF_9&~^g%D*PAb(TpDpeZmVaet z3Z8nCX68h_n9{;(f7YsMqvU-nzbunfI*tPeo<67~c5mr=QTsc;|M-BUyzPeX?Ik|< ziKp1B-;pSi?c_o}5nT;6()d(gJ01q+7`<@J#kol&LL`Jh?5%t_DrEd$c$ug=4nU-$ z*45lJo0`+$qM!^pn(7vc{XCa|+I6>Yn!peQeepE2^@(nJlgTEg+xX3z^_j*CgO`eb24?7;n!a|P$8o98g$(Z z+sHJ zgH|hS*yYDRkYyA>%dRhBR(}Ip%B&nBLI$t#U z&v)D#KhTN{+v`J=G=jQuCs(o_H7S}YAL;p!soQkcHD)v7Tz}8OMf-xgULOx{+4-)l z_?hd0KPKo-^9lqagXp*nf+c)Yv|g# z38Ow_4u0eaqysoD?K|$TG@`pBf&RuGK-EjVL13~YqoZ>DU!^YV_>ybcrc02BDK%%A z@iYROFR@QkOH)c116y+{2~(>~0%cykRMFJ&svxx&%}eg5 zmrECoc(tjYCoJ9`;?Vlfjxq<~uHK7eex~}t6J#)K!}};(g4>$16sPdn8#4R_%pl7; z;t{XaW3SoqALWDVcb6~UgjE{_d4%Nu)9nF;_EAiB0jms0mjEUOCtr1Us#j-}wFvWA3w6w#ptw7;Q@>AUvy)o#>a*iKMop9O!?GRt2s19;c=2DXG-VBLwqP_-vy z`(;^Sy7NsxKd?_U6qr!zi~x+qz-qH+5TY8_YsIBy+u?|FrJQ5C7H|bu4HbZF!eAgh zgRm|IldG7pv;&PX)P+k()RLh3$)E;U=FE+Ep|tS?KS&%_LSJCclsZeu2S$ZS!F9mo zJG&t8QPLj;xV2A*>LKZCWTVSBV)x*zkN1b0-bQKa@5mvV=OZ}_Cb|c%ZyCBk<`7}= z2QeaQbQqOa!(;9C4v=eC7?*7$P!d|&_I=wRzyfKR9t!JgQHO7e^78T!AE;;Or)8ZO z=DPh2RyEJN+1c4mDnkdMN2;-GCkkaE%>^W>C_>P)qr2kq=_0*R9zsxzp2-uw2nZ!) zJQx;VX#9x$0sbm6I9%ZUxO6bUYe?aaS}@31rk+Ojq|>F0fD^yth~rE~A_9q)N#A`? zC}Asx^AV%GA%_wa0G0v#F!A!P8%7$o5Cn8XnZ6&D5&C8(3AFRqu3h8UF$GW%m9RT> zKFDMHbgbe1GA|61wM#eLbs^Fe0j&bYn9OBdzHVY%e(_yU*&4`qKLlj&pHnN3y?ttM za0DF7B1i8)(3ce|c`&tMo#Y2IPrje!p!tc;4*Dq0Ud16>JPRa4i&==bY30@#v*(G` z3u1DiAI}T8r5=2NwuERNumBX809Jd}_4&LG20%!R1$zFI#|%1OGU#LI>&Sz&_#XNO ze1iM!%ZTnMel^jDj_zS+=PKEXH4+f;lfmWrMi76K_{|o_@=rbT-*yy{6dwkcMjp#D z2}9aCGc!Y4yx)F1J`5?mZ)E1Sjzk5P>kPo!PP=;h6@oIs8}34y7Us*uSS{K@SFUXl zqL<>juj8QOi&RrOjUg}x2M6S9Sf($kAbN>Q0z*AYC+IjhH+?`9LYY<>%-JVtG5^wHiM?&q;UP$ED5Ln=|BIM(#zJ&}=j6WEaGg7={WUDO5le)%g$^@we1vLtE= z>fQ||9?$nVeB6aDOYJnAERJwL1oh~NhJ`FxT|(z#wdj7uTeMFHHjV|H-v0SBp!oHv~Rm5r!)Y?mw|digl_Nt5%z zKzj!}N+7}l#(R~#6n_3YnF0B;|J{6v7yD-T1Xb+)uU6Nucp-IAA`%HhU?&MBM2mX# z)!f_^^9r9RPVBuIxrDC_U!0~U6w$T})-b`|r&JJ|=?{5v^}XFIdY99+IZT_=wpcj?LTf7ZU?dVX(POZWD`bVc_Y3LWHh|Fg>MZZk9>Ut!mhIdr;DXJr^TGD66M zBo^u4x0TqrP-~(>(ahK&&vHP<%WZwL|8(+z|z^+}d zNB0n~5kNCm?RyC1r_S^f{udj=Gn)RP_XpftEvV1kf4ti_m{9dV zOvRq4y@Pn>AewmyjkEe!Y{gIx9f3+V!s@&!w*&whl-EtyKvIE2A4NS9*Zdy)9v~6M znp&O4XB)FVrD$MgFZ;Mk^}Ub&0Z+fqx>&Z=?|6A*kC79POH1k2@>|8VBv zR;eL{2<9x(1|Fja$5@ajWP1x!)!vaN_%~45*86HY(FO?rO5w8&EbK;w&$$B<+^R%I)c3&e1P=}FsGw>;K-vwFfl@R z%d`QCCk5{0l%m;Ma%zp%AV%kLBk%wNBo?tlyZtwDo{x_F)YWz0E(pCO5k29N4S{yn zMpypvw#K*4{-_GpJE(;)Rs3uN{W5yL7NrsLX?+JDZAQ<&zMtIO|KFoIgt3+@zJ&yAkx21-LTgvC4ko9xM)0HRWp&-q22 zu`?YZp`EWoBRMlQ1#jfWg&3Shc(ib8orVX{j`S3dS6<8CK!o0iObhu}G2)iF&CsrA zx?804j^0)&#px0%1*QZ5r5064h{Gl%)W~93>a=0HE-8V7{XY@VvvF{$m~hf9EQW?|lsMl63sm9l z%DfSpNxc0Cun8_MS*Tvl?eXs3Zr@$YumAcdUpyeQ$%({;ji^pB;-{6)y+2DC-x^xt zW;wp%_@AY1F+riIlF68#_B1}3P${O9`VsQSCKraY12A)gnwF?M$&Ou=Wad?P7+9or zlnQxL7vY!jQdw$bZ%NxWlF$TV&3Gp&i48!D#)kFKr~&=uW>3cIG5md;zBp&%poeyW zVaE|&*Ejan5*Jr&$2muU3(^mn#Y!+^wl8MzO{V<#sN1QCtOA|cSstp6EC;ckb%}$l zCwe9nl@nG!ap-KrNkmo<(wCd+yvhgyTMVNVy+9mj+XjhADd4O;0P@Ij5HOHoSmYgxgbpY)y$}Py{3C$5 zk@#@;8S;InwFZK9EpKDM6tKX>1vJ9{1GV~}cbuTiq4Gu3(Fypxup2Z82VXixin0G2 zcb{~UIRwKo#v#l-qCY76uf~JufGkAlUS1+^!}ZFd(JtLWDW+t|&%ebE>tDXmSFE6t zLtUs86cjY@393(>UeaD7q1u4`N3fDW%0^j=p5` z&9=VZ`tfpqCb&}4NDY6S+uVIb#2UOK38vP*C^NYgGq+2vFeIB%SZv(`zw~QPliH>y zm`6uF=}E97Ap>*!ePsMk#2|E|3dhh$+j-~Tr#931OggybK-9v_fpqy^?rfBB8lo-5 z9}5Ib+mDEMxVRs(^@NTV6|f2_($x_sP>(%fvBz&2HH>$5O5 z<68mOr9v{eBpUvH4D|-=B{@2`LY;{K4J0IO1TG+?*F%USy*?fhbZGmUui=kdv04rd zVo-amEnW@9&Kxp7ZhJa9B-H3a7blk^bHGmr=wt4gVepMe6|S}ckIE-Pb*)jb4!?+XOnFVv`9vMNQmhhP9sgW8VD?U{#chOZmh z&mhWJ&?v@3^{`m%hGr}p?ub|siNOfSx6PIk13!5-R#vh`K_`7C2$CofsN*WtYPjm^ zI7e_c%#T17B@%#Y=n<>)m$&P!zM`w9Wf&N&4H~Z>XhW3b+)W%w21in?zd}vY_iJsT z_+)yJj`Tf~7nizDS)IicSHcMtK-pg(v8IU;O3^X9JT=#(sOKqe9iF;*tNE7H3nolN zoEeZPnTs|9D(<>I_smsfJ1>4eo>Yb*YaCIMdiDm~Nq|>A>q>E97>cGCYv~hTWT(50 z+Wz(>X{ASkR0b(xY;F?+*p5jFPPLTF9}?d{=F^GEr+#R2rnpJlzO1nHA&3~x?vRw) z+86(Nwd2m_hpT%MUeFJavFd|M6Jr+rzJ9gvM{JPeqwXS0QUU`f5*gm(RU-n^Lduia zyQPn<6?~FizfXxepeysg+WXF+DB5n@Mnz13N>ZYNl7mW8qKY6%Ni!rt1d%8V86+E# zEJ}_ldB{l^kf0zr=gb&@my94HVc_oOd(XLbs&0L6-8$#z=|2X#dxq{O?7j9{YbV|C zDufi$OE*^c$Ad>+yp+<8~f#dY;J~v)v>_*lLk*II!qT+o`0S>Lt7}cq-bgJBun|zyyIOeLY6@6i zS`Qy)Z)WP=7zz-J^;^i+*B8ylyRKcEdGR?6!kbi>Qvj#tuTa^j(Y5|=g-`uDEHmpL z`QEA3VNx;x>1AuvR_Tq@c>Vq-T3fcy`ofc_p7BlssEM6vMYyWYv>w5p+gU*tLJ}?I2#g`;v04Mna-5jHUQS4e9CZRlAyZ8Y zBKtl+2$5#r#ARQDa4Q_h+m>8bYeNkU8N$=N^57!W)<>^=9J8KQZqX(+_$9o#X&mdU1CiC% z$Kn-+_4GN=A1I_2Dzj{*6l5rB6$8iZqWUgYmDf{4G_`Ov+Yn@rbfy`{#ELXUtpz{p zs5vnj%w?I)23ZN5Czoi-mdW5RJ<2Oj&6rHns68x_Q#po7nVGM2*UV5qyJ`#QnWpVU zlx;8-a7=UuBI@2pC92l8vxES-mbJ@D*p%7VRw{4Yxn&exDpi&Bc^v(f!GIk@vU(8V zYN1cJ!TYo=M9U^~bwNT3LmH-G1)D*pA^EG?tXt`_E z<&ySR;B89HJZgB(YtrKhj=I@&;yUXVqRzcMAsSb5LNa3rZK(EkSTvF^zif)vg20$u z(*N%L@8L21SCh>zXp3V`yW?Q|52u?^K%Ck9!=x!?7%MmP!hbrPd@$=_bzFZr-=Q9g zPJ`|Dk6~MntCbNL)=MjxN)yNy#ZpCQ2fHL~=6y7S9@fLQ64$K;a%~I)9VmqP+Ketf z`|v`$XEnW^#PN1eelEI2KI>jTM!2h(5hs=@b4dAm-gmtm1ThdtQSs;r!`6JOlL?k; z{a4Ma7g~A+bd(-aXYpgsrX0C4Nw@Z*SwEo&TZH0_EdyFMX0xYT)>Lme>W?QhUyx)t z-sfy~^+C}rdjflZMbmAAijGc)F%e@4$3M%)3C!9FnT)FBcAyX5D50uY6kW?_`FY^L zfsE#z zE%wh=Oay$(4GMG#o+1FpF_>Uh|8?hZpUZ_L+Kwc~_0Sr??IdyIQY;$HLdAN;H~9x@ zZ>;r!y|w3h2uLYR&+NSs2(D(CGXpf~40)1@?h$ea)#kVkV)ZhAs2>LE$`!3QWm@Oc ziq?;a!ii}IZpi0tNi&YGc9IEPmmdRY7<;=SDll^s_Ec;YKBhdSyM452$&1bD$*}Y& zVX8wgZT@%g_M)7=qZSL0qwM>B4AV+X`-AA!gf(r@h&D{M}l)qV0iW*jHX#Baj-%xsh7bWm2LvMz1 zf|{^sX^q9w!@>Mdae~Nx)6p(CS3({s2Yyh3)CDDuh^wp}JpvM7KePvM zZM2a_*Wn+<0Tg}Si=XNs%yBLsImUMyZO7G&iqO-u$J=NqFTxQo7x}V|Xr-DJ3eYn$S_xKN5Gu*L z<-a)TqJ1XSLiI$@3XcH$&;!Y-Mu0)=yJBPSnE>As&;wL)#fhO_khk{NFJ}LMW`veK z^-@)z;;{J!WPXu!tgN=S>fMnTsT2WAR-;(KsR0Ge+$R;7ido|b8_S#u?z0lWz6ao| z@6kF}++8-WasU?Qsw9i~9r=O@`qL2Knr%aVJI3W?iJ&7` z1@r5CHX-C{T>3?sd=*gLGyDG~j_4tx!7SkS_`e`{nXB`FE%K4d^nL6$_jmQl4?yWO zrNJ{2JU3t`rIVjxV9uU#>>& z^71i~zL>wPM0?6*mL3mw+N9u{H40u%W{m1C$O(1iyO(?Xns zy@Kbcs~Y0US;*ehZt44!oP53E#b!4CZbA6(?Abf(G1d;)dHr8dbOV`YTkzr78^XQJ zB3}UuS)kBuweMmMzm@TrXQfh)yaoFh#RC8iG;@R<_^98rXDJ_2VcH?Iaij$dEVxTfML?*1?|Zb&5XrDtqr$Ww8JxXuO(m}hR@HWD4$(B zjAk@+ZZkv?ojSLT%&u?6MZMrHBBQ06xkR~o4%2pnFE{ifr%v}f%Qgw#^6qZP_8+n; zW(z*5a)(6o|FTIEai2>^_8K|dfB7%JINxN*;qlr&ppfhK7zsy}9$<8>lWRK0seFO1 z-(dCZ=;7vL#9cN&yK-JpqeG`wUW0ic9v(0jtljzVH~<{U(*p7{p{L!Dv{ zR!=aJK#i{9wV~cTo$8gSbs8)^XuaU<^H3%}5|oQWA(e-WA9x)OJg5SBPc1~e&@38m z%l%!k@U=2jt$gw zuLkE5sL*|}6&&!)g5n!YJ(L5I^Xp}3C=|6up#BG?&bO$75jD@!Oa;T~B(y$Mfx1fh z-Ulpd5|%K3C4ZcaZxI0o?NZ4gw6aCpX?{(3py5Q5M>eA&U?QQHTnw)NBhc6dI7m2$B7rUOF7m+dAAN_8+K3vDE5FF zIHR*L*;l7>oPa_Z$IdD_1_rThz&2^KgF@IZSJ>+J%7>`C4iVvuds%fVC!XCrBwkx$ zvN5c7?QIuR+hstgq&`Z*UJfJ%YS8{c(OEw>3YR(nMrdPsV;O&$RA&Pb2^frfFuNBD zZl0!91p^!GS@{q1L)lKvr^B^q^u_sQWX5YN;KjpCa8XoLgipUyYyl=sxM(h4cW=H* zP){cb8Odx-S~6^*t%^pCpmIT6-1E>f&aGBdRdmI?Q`!+W{&uVJvJ@2dyMf9COqeks zL6MwyZD^@+sjvpAL7Y!ofgQ4|tLwK4NOIR*Q9a>0T5~p6za9oo%p_7rM_t+*&Wo;T zqeS7$y=1u9=g?RP0BtB(qXtKiPkr=kQGp^LwER$<8v~SJvMmlQ3ZP>XY74;(r4Z&# zkDcqA`$K-Iy~cw%mFKqKbw3cB0O6&T)$!*ydD7bY`ucKYgg00gL3=Pc{UzvkoD1}% ze?4<=s1T#J2wzBy0kBJWNRRn&euN#s)YR0e^^4|tW%pg{JOC)-#Bkk4KC(WtMzU-S zbL9Z)oda!3Uny-BcjF(FK(=om(81v`kRWp93;L@kJ=CvMr<4)X2ps)7Xax zg^NuQlw3-&ypaS0=w_mS(90m*YG{QNnIpZ+pc9z5qs@+56H z;&Gr9ml&Xxj+*Dfh6wT45l&i7ZhVG8rb9dP6_id^Tg>LVT_D_dR7u2PnoY8g`0Y#d zu8eza`Hm9@gf&1pmlaPYULo!Gz8#jQgHKh9v_;_aEB^1-flC{8^G` zR`Nc=0{l2?WRHhxyvE{X&l~qp3@Z8oF*j7WmD5{zcM;bvo1d0cLnC z0JcNU17JJ&1myW8p+pX3anE({+!Q;NWd5PUR827)=CG{KCs`Nd5id~F_xX;E!%Rai z?a}25=@7c(>+{i+J>g@|ii*X$;=M1B88!+$ycbLQY5H&S{Tug-^u-ZC<*jJv13 zHj)BMb0p}EKYgXWO>$XI+1YaL@E5;>5ugokLk(om=2&*Z36(d>!-jx(Ug~bU`uWMN z6sDLUm%jp!iUC1tk9$N+>&o2WI}HE-$|cLiR)6XC*7w1wWk>o;3AetQmpK02UkCc) zxMe*4>cr}@81Lny- z6S!okMTg&1bKU^4JC1U@(Bt0@G}LGo*{S|hm(p~HDsKy<(HSl0<%%YZ3KUmQ8Z*j* zKZFj=n-^j^7H1usP7tfVcDXFxm(eZqAo&Y_m*{_FyIAkc)t(Yx*U@0^y-Tm<%AA-# zbG6x(0>igqmvbqYiv;|;y{alwv%CH~z|cB0!SYTT(TJD9(23YP(z3f;kZqP5v_EE$ zfoUBl?L2;LNtVXz{8{^{MH(LkquV9+@OoOM8+Kp51jT9D8}RrBQm(%1xA_2$u-aIx z6^1|W?g#D@V(x9@xKg%T^mf~2tADniawVJOiSZ=j?1E1Y?ce@R^F|?WD7gu{DaQSJ zddB@h(dL%n$CI+gJ@5J&*o!FsICJ00Fi&NtU&L&+D+o!rb$jcHxpvmfo;P2T4r`mh zP3^z6TVSU$fj_VEY%029sl!KbDDB`FAAKcrQvR&&4H#63nL1T&79kwd_v>>eBz=6! zV&rzM&QoB8Cg;8j8-L&78KH)u00K5>o{DS_e9&SzLjFK>=EYCXrw%j{eJ(g*uMU47 zoUDV<%#+Yw-WP~e z)$60}(qqT2(0(3Rt6uFj-9WZ=S|k1#3>CWB3VYz((pR3B>+olPHPG9CSFe~f#P=ZY z9OvCzsz<6%-MPa5y;Rj*jwsD1y4e>7~Tul&W1 za4nroV#NHIr4O?tz4J`1Xa!rgM!Eh@r*zov?o>D;JMpT zy*xQ~cFx~{?Q2IU9!Q4Oj|swKy8-bcLaYwAji;`jCA7t;=&YFT;trjRVR@o3Z*_cWbL5XSah+Ix4rGZ>;m`L(eBWW<G`DlZEmp~{8I^3mBjF=F%kOT?RD_}f zh4WtnMW=83EZxdMN~0?4`K2v-Heku)$BNdUuV-quR^DoGAfH~OG;&-1HT>P2^P$9G zwwrD{|Bmdu)bC&MxBQiqN{&t`7rrh$_~#(SK@pzBr8yhF2={pznl)n3TF-iX{wsoo zkdRkCHc$D>heQdVzec}g`77zroP_PlmIjSWkll&pNG$XK9p0L5mG+4|)V8y(uwKj? zF;RJKVC8vU9hY}0sqqWn&)~pGVI8Yt0j+y#rcUc@+Fa-L)&hRa=%kh@P+6)kwzT=} zN;uX&ALJ8InZ#i)RX=Y%&$~EV=Ip_ir)8_Via!zc?S;@tZZ)jzrT!g2uY}HHo9EA; zf7DYiQpCi?$vONT8nBw3b-Z$d&gSeYFLV6nf*4Oyvw!Q4&~%BkEYjbWXIs@bBcr4K zldSvyzhyzqwc6{|qep`TLg7K(zZYP1_>GE?d2h2>r%f79N4B$=o_%97z0ddQ^3nx( zRbIa{Al~+QN90iksgV(pG4v}l^8DC6v}api#nEN$X)teHQl&NYM6j}lc6K4 zEarNVS;;HHAB$46)#5vjW=$(M61vH%wu^YRn&^q^PGf}zk>m)~H=tTWTgi!mQaiiO z)GQq#qu}b-uU~QatEtjaD%GZ+PY92mxXP0eKpJFl3Cp!Y+)Ma zf`$b|)klTqnXkmlG`c?Ks7$YWPEsMn+qF7ku;V8wf3w#9(x?hwM%wwJ!uf)oqE|e? z9)D&=37Ny5xiDgvT;{;lv%J&H0wgcgb_XF&5#b+*El1)Da^&=Lhk<0w`EBRR;s)A6QJe)0UsyNI z7a>jGbEmtSW(QSKuYxxwNX-JebCw$D?+!{sBSZ+j;o7u(i!OGr0xl-Y9Urlk)^_&kn~g^@iPL>zc0kV_ z9Uq6YjSLM*Mb7s28f5vlOW?KvT~Tp3P?f{|obkB|QMi{3h$GEqMA_ofO(scqiZ$Ks z!jd8UE_HrozK7H)Wh5Fat|~&8EN$zka7Wxd%8iWTs?t7+8k6C$QcGr=m5k7lF>HH# z`|xTUjVYDyC54q69*(w5H8Sl^fTc!L{?X6q2?=x^VFi zW5~FUtbWxye}ozC!fpQ1zIV7Zz=dn<=QCr@gWUI*y+^!gZaQ0xA@@$g(>QS3`wzbB zQ@sXBoZE-+=m-)xml`~qHLa&m*0EppI^4l(Lad-7CV8Wdh8vkHz!}yza_}Ow9Ck>! zSeaw9?bL4g%wj_WiH@ityCC$PABjlX@4IoB87UI(-M4KIgZIetYYpF% zv0eQKxe=e>Vlwrh8Zz~eRQM^{qpT~4H;v)` zd)MGQy_PE8w=4;#2EurYG`~i1l^cE{4IfQ6L)q+3Q8UcVh-96&L$U{|F6#e5qT^nX z9luFL8t=~^M0{CCAlyfgp9jzd=u9HadjzS28$l@PwhvvMj>79!D&|72c|CjN+${OudL*Y7>~KbT{3 Am;e9( diff --git a/docs/static/img/databricks-uri-migration/before.png b/docs/static/img/databricks-uri-migration/before.png deleted file mode 100644 index 018112c3f22773bebafbc24248ddd319f8195023..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42189 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 dict[str, Any]: # the sqlalchemy_uri # When returning the parameters we should use the masked SQLAlchemy URI and the # masked ``encrypted_extra`` to prevent exposing sensitive credentials. + # An unparseable URI raises here rather than yielding empty parameters. + make_url_safe(self.sqlalchemy_uri) encrypted_config = {} if (masked_encrypted_extra := self.masked_encrypted_extra) is not None: with suppress(TypeError, json.JSONDecodeError): diff --git a/tests/unit_tests/models/core_test.py b/tests/unit_tests/models/core_test.py index 8120588d5422..b7e488a50e25 100644 --- a/tests/unit_tests/models/core_test.py +++ b/tests/unit_tests/models/core_test.py @@ -40,6 +40,7 @@ from sqlalchemy.orm.session import Session from sqlalchemy.sql import Select +from superset.commands.database.exceptions import DatabaseInvalidError from superset.connectors.sqla.models import SqlaTable, TableColumn from superset.databases.error_provenance import mark_database_engine_error from superset.errors import SupersetErrorType @@ -288,6 +289,19 @@ def test_dttm_sql_literal( assert SqlaTable(database=database).dttm_sql_literal(dttm, col) == result +def test_parameters_rejects_invalid_uri(app_context: None) -> None: + """ + Test that an unparseable saved URI still raises instead of returning no parameters. + """ + database = Database( + database_name="db", + sqlalchemy_uri="postgresql://user:pass@host:notaport/db", + ) + + with pytest.raises(DatabaseInvalidError): + _ = database.parameters + + def test_table_column_database() -> None: database = Database(database_name="db") assert TableColumn(database=database).database is database From 54f95ada005a48e5baa6ca86ea5d6f7501d8837c Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 29 Oct 2026 11:30:18 +0000 Subject: [PATCH 05/12] fix(databricks): keep the stored token when saving a converted connection A converted connection keeps its legacy settings in connect_args, while the native form rebuilds them into the URI query. The credential-reuse checks compared the two URI spellings literally, so saving an unchanged converted connection was treated as a destination move: validation tested it with the masked token and the update was rejected until the token was re-entered. Compare the stored and submitted URIs by the engine spec's effective destination instead. The Databricks spec merges connect_args and the URI query the same way adjust_engine_params does; other engines are unchanged. A changed warehouse, host or connect_args still requires a new token. --- superset/commands/database/update.py | 5 +- superset/commands/database/utils.py | 31 +++++++- superset/commands/database/validate.py | 27 ++++++- superset/db_engine_specs/base.py | 8 ++ superset/db_engine_specs/databricks.py | 18 +++++ .../commands/databases/update_test.py | 61 +++++++++++++++ .../commands/databases/validate_test.py | 77 +++++++++++++++++++ .../db_engine_specs/test_databricks.py | 27 +++++++ 8 files changed, 250 insertions(+), 4 deletions(-) diff --git a/superset/commands/database/update.py b/superset/commands/database/update.py index a0fe624b38a9..9fd0b099b726 100644 --- a/superset/commands/database/update.py +++ b/superset/commands/database/update.py @@ -305,7 +305,10 @@ def _check_no_unsafe_secret_rebind(self) -> None: if "sqlalchemy_uri" in self._properties: submitted_uri = self._properties["sqlalchemy_uri"] or "" connection_identity_changed = uri_identity_changed( - model.sqlalchemy_uri, submitted_uri + model.sqlalchemy_uri, + submitted_uri, + model.extra, + self._properties.get("extra", model.extra), ) try: submitted_password = make_url_safe(submitted_uri).password diff --git a/superset/commands/database/utils.py b/superset/commands/database/utils.py index ef428c110658..0b6358832b8a 100644 --- a/superset/commands/database/utils.py +++ b/superset/commands/database/utils.py @@ -45,11 +45,33 @@ logger = logging.getLogger(__name__) -def uri_identity_changed(existing_uri: str | None, submitted_uri: str | None) -> bool: +def _connect_args(serialized_extra: str | None) -> dict[str, Any]: + try: + connect_args = ( + json.loads(serialized_extra or "{}") + .get("engine_params", {}) + .get("connect_args", {}) + ) + except (TypeError, ValueError, AttributeError): + return {} + return connect_args if isinstance(connect_args, dict) else {} + + +def uri_identity_changed( + existing_uri: str | None, + submitted_uri: str | None, + existing_extra: str | None = None, + submitted_extra: str | None = None, +) -> bool: """ Whether two SQLAlchemy URIs differ once their password is stripped -- i.e. whether the effective connection destination (driver, host, port, database, username, query params) changed. + + The stored engine spec compares URIs that spell the same destination + differently (e.g. a setting in ``connect_args`` versus the URI query) by + their effective destination. ``engine_params`` changes are checked + separately by ``engine_params_changed``. """ try: stored = make_url_safe(existing_uri or "")._replace(password=None) @@ -58,7 +80,12 @@ def uri_identity_changed(existing_uri: str | None, submitted_uri: str | None) -> # An unparseable URI cannot be compared: treat it as a change so a # stored secret never survives onto it. return True - return stored != incoming + if stored == incoming: + return False + engine_spec = Database.get_db_engine_spec(stored) + return engine_spec.get_connection_identity( + stored, _connect_args(existing_extra) + ) != engine_spec.get_connection_identity(incoming, _connect_args(submitted_extra)) def engine_params_changed( diff --git a/superset/commands/database/validate.py b/superset/commands/database/validate.py index 6edf192d8426..d399b1a0a588 100644 --- a/superset/commands/database/validate.py +++ b/superset/commands/database/validate.py @@ -22,6 +22,7 @@ from superset import is_feature_enabled from superset.commands.base import BaseCommand from superset.commands.database.exceptions import ( + DatabaseInvalidError, DatabaseOfflineError, DatabaseTestConnectionFailedError, InvalidEngineError, @@ -30,7 +31,9 @@ from superset.commands.database.utils import ( engine_params_changed, ssh_tunnel_endpoint_changed, + uri_identity_changed, ) +from superset.constants import PASSWORD_MASK from superset.daos.database import DatabaseDAO from superset.databases.utils import make_url_safe from superset.db_engine_specs import get_engine_spec @@ -130,7 +133,7 @@ def run(self) -> None: # noqa: C901 self._properties.get("parameters"), encrypted_extra, ) - if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri(): + if self._model and self._reaches_stored_destination(sqlalchemy_uri): if identity_changed: raise InvalidParametersError( [ @@ -217,6 +220,28 @@ def _load_model(self) -> None: if (database_id := self._properties.get("id")) is not None: self._model = DatabaseDAO.find_by_id(database_id) + def _reaches_stored_destination(self, sqlalchemy_uri: str) -> bool: + """ + Whether a URI rebuilt from masked parameters names the stored + connection's destination, even when spelled differently. + """ + model = self._model + assert model is not None + safe_uri = model.safe_sqlalchemy_uri() + if sqlalchemy_uri == safe_uri: + return True + try: + password = make_url_safe(sqlalchemy_uri).password + except DatabaseInvalidError: + return False + # A newly typed password is the one to test, not the stored one. + return password == PASSWORD_MASK and not uri_identity_changed( + safe_uri, + sqlalchemy_uri, + model.extra, + self._properties.get("extra", "{}"), + ) + def _get_database_name_error(self) -> Optional[SupersetError]: """Check for duplicate database name and return error if found.""" database_id = self._properties.get("id") diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 0e89d3e95835..2822999f51a6 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -3217,6 +3217,14 @@ def unmask_encrypted_extra(cls, old: str | None, new: str | None) -> str | None: return json.dumps(new_config) + @classmethod + def get_connection_identity(cls, uri: URL, connect_args: dict[str, Any]) -> URL: + """ + Return the destination a stored credential is sent to, for checks that + decide whether the credential may be reused on an edited connection. + """ + return uri + @classmethod def get_parameters_from_database( cls, diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index e008c347a4cb..811ca9a4c5b6 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -924,6 +924,24 @@ def get_parameters_from_database( parameters[field] = database.connect_args[key] return parameters + @classmethod + def get_connection_identity(cls, uri: URL, connect_args: dict[str, Any]) -> URL: + """ + Describe legacy connect_args and native query settings identically. + + Mirrors ``adjust_engine_params``: query settings take precedence over + connect_args, and the URI path is the schema when neither sets one. + """ + query: dict[str, str | tuple[str, ...]] = { + key: str(connect_args[key]) + for key in ("http_path", "catalog", "schema") + if key in connect_args + } + query.update(uri.query) + if "schema" not in query and uri.database: + query["schema"] = uri.database + return uri._replace(database=None).set(query=query) + @classmethod def get_default_catalog( cls, diff --git a/tests/unit_tests/commands/databases/update_test.py b/tests/unit_tests/commands/databases/update_test.py index 34d04bc87622..e2f2a607baed 100644 --- a/tests/unit_tests/commands/databases/update_test.py +++ b/tests/unit_tests/commands/databases/update_test.py @@ -1285,3 +1285,64 @@ def test_update_oauth2_unresolvable_endpoints_purges_tokens( command._handle_oauth2() purge_oauth2_tokens.assert_called_once() + + +CONVERTED_DATABRICKS_URI = "databricks://token:XXXXXXXXXX@host:443/default" +CONVERTED_DATABRICKS_EXTRA = json.dumps( + {"engine_params": {"connect_args": {"http_path": "/legacy", "catalog": "main"}}} +) + + +def _converted_databricks_update(mocker: MockerFixture, submitted_uri: str) -> Any: + old_database = mocker.MagicMock(allow_multi_catalog=False) + old_database.sqlalchemy_uri = CONVERTED_DATABRICKS_URI + old_database.extra = CONVERTED_DATABRICKS_EXTRA + old_database.encrypted_extra = None + old_database.ssh_tunnel = None + old_database.get_default_catalog.return_value = "main" + old_database.id = 1 + + new_database = mocker.MagicMock(allow_multi_catalog=False) + new_database.get_default_catalog.return_value = "main" + + database_dao = mocker.patch("superset.commands.database.update.DatabaseDAO") + database_dao.find_by_id.return_value = old_database + database_dao.update.return_value = new_database + + mocker.patch("superset.commands.database.update.SyncPermissionsCommand") + mocker.patch.object(UpdateDatabaseCommand, "_update_catalog_attribute") + + UpdateDatabaseCommand( + 1, + {"sqlalchemy_uri": submitted_uri, "extra": CONVERTED_DATABRICKS_EXTRA}, + ).run() + return database_dao + + +def test_update_converted_databricks_connection_keeps_masked_token( + mocker: MockerFixture, +) -> None: + """ + Saving a converted Databricks connection from the native form rebuilds its + legacy settings into the URI query. The destination is unchanged, so the + masked token must be accepted rather than treated as a connection move. + """ + database_dao = _converted_databricks_update( + mocker, + "databricks://token:XXXXXXXXXX@host:443" + "?catalog=main&http_path=%2Flegacy&schema=default", + ) + + database_dao.update.assert_called_once() + + +def test_update_converted_databricks_http_path_change_requires_new_token( + mocker: MockerFixture, +) -> None: + """A different warehouse is still a destination change for a masked token.""" + with pytest.raises(DatabaseInvalidError): + _converted_databricks_update( + mocker, + "databricks://token:XXXXXXXXXX@host:443" + "?catalog=main&http_path=%2Fother&schema=default", + ) diff --git a/tests/unit_tests/commands/databases/validate_test.py b/tests/unit_tests/commands/databases/validate_test.py index 85d4cf9268ff..22c2a3315df2 100644 --- a/tests/unit_tests/commands/databases/validate_test.py +++ b/tests/unit_tests/commands/databases/validate_test.py @@ -894,3 +894,80 @@ def test_ssh_tunnel_missing_message_is_interpolated( assert missing_field_messages assert all("%(missing)s" not in msg for msg in missing_field_messages) assert any("server_port" in msg for msg in missing_field_messages) + + +@pytest.mark.parametrize( + "rebuilt_uri, expected_uri", + [ + # unchanged native-form fields: the stored token is rehydrated + ( + "databricks://token:XXXXXXXXXX@host:443" + "?catalog=main&http_path=%2Flegacy&schema=default", + "databricks://token:real-token@host:443/default", + ), + # a different warehouse keeps the masked token + ( + "databricks://token:XXXXXXXXXX@host:443" + "?catalog=main&http_path=%2Fother&schema=default", + "databricks://token:XXXXXXXXXX@host:443" + "?catalog=main&http_path=%2Fother&schema=default", + ), + # a newly typed token is used as typed + ( + "databricks://token:new-token@host:443" + "?catalog=main&http_path=%2Flegacy&schema=default", + "databricks://token:new-token@host:443" + "?catalog=main&http_path=%2Flegacy&schema=default", + ), + ], +) +def test_validate_converted_databricks_connection_token( + mocker: MockerFixture, rebuilt_uri: str, expected_uri: str +) -> None: + """ + A converted Databricks connection keeps legacy settings in ``connect_args`` + while the native form rebuilds them into the URI query. Validation must + still test an unchanged connection with its stored token. + """ + extra = json.dumps( + {"engine_params": {"connect_args": {"http_path": "/legacy", "catalog": "main"}}} + ) + existing = mocker.MagicMock() + existing.safe_sqlalchemy_uri.return_value = ( + "databricks://token:XXXXXXXXXX@host:443/default" + ) + existing.sqlalchemy_uri_decrypted = "databricks://token:real-token@host:443/default" + existing.encrypted_extra = "{}" + existing.extra = extra + existing.ssh_tunnel = None + + database = mocker.MagicMock() + with database.get_sqla_engine() as engine: + engine.dialect.do_ping.return_value = True + + DatabaseDAO = mocker.patch( # noqa: N806 + "superset.commands.database.validate.DatabaseDAO" + ) + DatabaseDAO.find_by_id.return_value = existing + DatabaseDAO.validate_update_uniqueness.return_value = True + DatabaseDAO.build_db_for_connection_test.return_value = database + + mocker.patch( + "superset.commands.database.validate.get_engine_spec", + return_value=mocker.MagicMock( + validate_parameters=mocker.MagicMock(return_value=[]), + build_sqlalchemy_uri=mocker.MagicMock(return_value=rebuilt_uri), + unmask_encrypted_extra=mocker.MagicMock(return_value="{}"), + ), + ) + + properties = { + "id": 1, + "engine": "databricks", + "driver": "databricks", + "parameters": {"host": "host", "port": 443}, + "extra": extra, + } + ValidateDatabaseParametersCommand(properties).run() + + database.set_sqlalchemy_uri.assert_called_once_with(expected_uri) diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index ab720f6266e5..108b2989dd4f 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -1728,3 +1728,30 @@ def test_native_form_save_preserves_masked_token_and_updates_legacy_settings() - ) assert "http_path" not in args assert "catalog" not in args + + +def test_connection_identity_matches_legacy_settings_and_native_query() -> None: + """Legacy connect_args and native query settings name the same destination.""" + legacy = DatabricksPythonConnectorEngineSpec.get_connection_identity( + make_url("databricks://token@host:443/default"), + {"http_path": "/legacy", "catalog": "main"}, + ) + native = DatabricksPythonConnectorEngineSpec.get_connection_identity( + make_url( + "databricks://token@host:443" + "?catalog=main&http_path=%2Flegacy&schema=default" + ), + {"http_path": "/legacy", "catalog": "main"}, + ) + other_warehouse = DatabricksPythonConnectorEngineSpec.get_connection_identity( + make_url( + "databricks://token@host:443?catalog=main&http_path=%2Fother&schema=default" + ), + {"http_path": "/legacy", "catalog": "main"}, + ) + + assert legacy == native + assert native != other_warehouse + assert BaseEngineSpec.get_connection_identity( + make_url("postgresql://u@host/db"), {"host": "other"} + ) == make_url("postgresql://u@host/db") From a7badf30d3372822bba048f750f4babfca640d0a Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 29 Oct 2026 20:39:57 +0000 Subject: [PATCH 06/12] fix(databricks): preserve legacy connection edit and creation flows --- .../databases/supported/databricks.mdx | 9 ++ .../databases/DatabaseModal/index.test.tsx | 88 ++++++++++- .../databases/DatabaseModal/index.tsx | 5 +- superset/commands/database/validate.py | 24 +++ superset/databases/api.py | 15 +- superset/databases/schemas.py | 6 + superset/db_engine_specs/base.py | 7 + superset/db_engine_specs/databricks.py | 41 ++++- ...c91d64_migrate_databricks_connector_uri.py | 30 ++-- superset/models/core.py | 17 +- .../commands/databases/validate_test.py | 146 +++++++++++++++++- tests/unit_tests/databases/api_test.py | 23 +++ tests/unit_tests/databases/schema_tests.py | 36 +++++ .../db_engine_specs/test_databricks.py | 95 +++++++++++- .../test_migrate_databricks_connector_uri.py | 57 ++++++- tests/unit_tests/models/core_test.py | 29 ++-- 16 files changed, 576 insertions(+), 52 deletions(-) diff --git a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx index 79c8f0471924..8d8e2a11df1c 100644 --- a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx +++ b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx @@ -147,3 +147,12 @@ Imported connections carrying the conversion marker are also eligible for rollba scheme changes. The migration keeps fingerprints in a bookkeeping table so these connections can also be safely downgraded without parsing their settings. Invalid settings may still need correction before the connection can execute queries. + +The Default Catalog field is optional. Connections converted from the legacy +connector retain the legacy runtime default catalog when none is configured, +so existing dataset catalogs and schema permissions remain consistent. Clearing +a field removes its corresponding legacy `connect_args` override. Changing the +connection destination requires re-entering the access token. + +The new-connection picker offers **Databricks**, not **Databricks (legacy)**. +Existing legacy connections can still be edited with their saved form. diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 915658bce6a1..7f9d720c1414 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -44,6 +44,8 @@ jest.mock('@superset-ui/core', () => ({ isFeatureEnabled: () => true, })); +const realUseSingleViewResource = hooks.useSingleViewResource; + const mockHistoryPush = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -1484,6 +1486,72 @@ describe('DatabaseModal', () => { }); }); + test('keeps a created PostgreSQL field connection on step 3 without a saved schema', async () => { + const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); + const previousImplementation = resourceHook.getMockImplementation()!; + resourceHook.mockImplementation(realUseSingleViewResource); + try { + fetchMock.modifyRoute('database-connect', { + response: { + id: 10, + result: { + id: 10, + database_name: 'PostgreSQL', + backend: 'postgresql', + configuration_method: ConfigurationMethod.DynamicForm, + }, + }, + }); + fetchMock.put(DATABASE_FETCH_ENDPOINT, { result: { id: 10 } }); + const addDangerToast = jest.fn(); + setup({ addDangerToast }); + await userEvent.click( + await screen.findByRole('button', { name: /postgresql/i }), + ); + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + const fieldByName = (name: string) => + document.querySelector(`input[name="${name}"]`)!; + for (const [name, value] of Object.entries({ + host: 'localhost', + port: '5432', + database: 'postgres', + username: 'testdb', + })) { + fireEvent.change(fieldByName(name), { target: { value } }); + } + fireEvent.blur(fieldByName('username')); + const connectButton = screen.getByRole('button', { name: 'Connect' }); + await waitFor(() => expect(connectButton).toBeEnabled()); + fireEvent.click(connectButton); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), + ).toHaveLength(1), + ); + expect(await screen.findByText(/step 3 of 3/i)).toBeInTheDocument(); + expect(screen.getByText('Database connected')).toBeInTheDocument(); + expect(screen.queryByText('SQLAlchemy URI')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Finish' })); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT), + ).toHaveLength(1), + ); + expect( + JSON.parse( + fetchMock.callHistory.lastCall(DATABASE_FETCH_ENDPOINT)!.options + .body as string, + ), + ).toEqual( + expect.objectContaining({ + configuration_method: ConfigurationMethod.DynamicForm, + }), + ); + } finally { + resourceHook.mockImplementation(previousImplementation); + } + }); + test('does not fire redundant validation on blur when db has not changed', async () => { setup(); @@ -2054,7 +2122,7 @@ describe('dbReducer', () => { const currentState = dbReducer({}, action); expect(currentState).toEqual({ ...databaseFixture, - configuration_method: ConfigurationMethod.SqlalchemyUri, + configuration_method: ConfigurationMethod.DynamicForm, engine: 'postgres', masked_encrypted_extra: '', parameters: undefined, @@ -2895,3 +2963,21 @@ test.each([true, false])( } }, ); + +test.each(['postgresql', 'mysql', 'databricks', 'gsheets'])( + 'only editing %s falls back to URI when the saved schema is absent', + backend => { + const payload = { + backend, + configuration_method: ConfigurationMethod.DynamicForm, + }; + expect( + dbReducer({}, { type: ActionType.Fetched, payload, isEditMode: false }) + ?.configuration_method, + ).toBe(ConfigurationMethod.DynamicForm); + expect( + dbReducer({}, { type: ActionType.Fetched, payload, isEditMode: true }) + ?.configuration_method, + ).toBe(ConfigurationMethod.SqlalchemyUri); + }, +); diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 3a21b90ebb45..bda0d68a338c 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -211,6 +211,7 @@ export type DBReducerActionType = } | { type: ActionType.Fetched; + isEditMode?: boolean; payload: Partial; } | { @@ -574,6 +575,7 @@ export function dbReducer( }; case ActionType.Fetched: { const configurationMethod = + action.isEditMode && action.payload.configuration_method === ConfigurationMethod.DynamicForm && !Object.keys(action.payload.parameters_schema?.properties || {}).length @@ -1538,13 +1540,14 @@ const DatabaseModal: FunctionComponent = ({ if (dbFetched) { setDB({ type: ActionType.Fetched, + isEditMode, payload: dbFetched, }); // keep a copy of the name separate for display purposes // because it shouldn't change when the form is updated setDbName(dbFetched.database_name); } - }, [dbFetched]); + }, [dbFetched, isEditMode]); useEffect(() => { if (isLoading) { diff --git a/superset/commands/database/validate.py b/superset/commands/database/validate.py index d399b1a0a588..0a4081e96729 100644 --- a/superset/commands/database/validate.py +++ b/superset/commands/database/validate.py @@ -83,6 +83,11 @@ def run(self) -> None: # noqa: C901 ), ) + if hasattr(engine_spec, "get_extra_from_parameters"): + self._properties["extra"] = engine_spec.get_extra_from_parameters( + self._properties.get("parameters", {}), + self._properties.get("extra", "{}"), + ) # perform initial validation (host, port, database, username) errors = engine_spec.validate_parameters(self._properties) # type: ignore @@ -133,6 +138,25 @@ def run(self) -> None: # noqa: C901 self._properties.get("parameters"), encrypted_extra, ) + if ( + self._model + and self._properties["engine"] == "databricks" + and make_url_safe(sqlalchemy_uri).password == PASSWORD_MASK + and not self._reaches_stored_destination(sqlalchemy_uri) + ): + raise InvalidParametersError( + [ + SupersetError( + message=__( + "Re-enter the access token when changing the connection " + "destination." + ), + error_type=SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR, + level=ErrorLevel.ERROR, + extra={"invalid": ["access_token"]}, + ) + ] + ) if self._model and self._reaches_stored_destination(sqlalchemy_uri): if identity_changed: raise InvalidParametersError( diff --git a/superset/databases/api.py b/superset/databases/api.py index 1fd874d0861f..129571de0303 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -125,6 +125,7 @@ ) from superset.databases.utils import get_table_metadata from superset.db_engine_specs import get_available_engine_specs +from superset.db_engine_specs.databricks import DatabricksNativeEngineSpec from superset.errors import ErrorLevel, SupersetError, SupersetErrorType from superset.exceptions import ( DatabaseNotFoundException, @@ -1999,7 +2000,7 @@ def available(self) -> Response: preferred_databases: list[str] = app.config.get("PREFERRED_DATABASES", []) available_databases = [] for engine_spec, drivers in get_available_engine_specs().items(): - if not drivers: + if not drivers or engine_spec is DatabricksNativeEngineSpec: continue payload: dict[str, Any] = { @@ -2255,9 +2256,9 @@ def get_list( # pylint: disable=arguments-differ @safe @statsd_metrics @event_logger.log_this_with_context( - action=lambda self, - *args, - **kwargs: f"{self.__class__.__name__}.delete_oauth2_tokens", + action=lambda self, *args, **kwargs: ( + f"{self.__class__.__name__}.delete_oauth2_tokens" + ), log_to_statsd=False, ) @requires_json @@ -2312,9 +2313,9 @@ def delete_oauth2_tokens(self, pk: int) -> FlaskResponse: @safe @statsd_metrics @event_logger.log_this_with_context( - action=lambda self, - *args, - **kwargs: f"{self.__class__.__name__}.delete_all_oauth2_tokens", + action=lambda self, *args, **kwargs: ( + f"{self.__class__.__name__}.delete_all_oauth2_tokens" + ), log_to_statsd=False, ) def delete_all_oauth2_tokens(self, pk: int) -> FlaskResponse: diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 56461a2ff715..28045e32c379 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -437,6 +437,12 @@ def build_sqlalchemy_uri( except json.JSONDecodeError: encrypted_extra = {} + if hasattr(engine_spec, "get_extra_from_parameters"): + extra = engine_spec.get_extra_from_parameters( + parameters, data.get("extra") + ) + if extra is not None: + data["extra"] = extra data["sqlalchemy_uri"] = engine_spec.build_sqlalchemy_uri( parameters, encrypted_extra, diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index 2822999f51a6..ccf35b273aca 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -3225,6 +3225,13 @@ def get_connection_identity(cls, uri: URL, connect_args: dict[str, Any]) -> URL: """ return uri + @classmethod + def get_extra_from_parameters( + cls, parameters: dict[str, Any], extra: str | None + ) -> str | None: + """Reconcile field-form values with settings stored outside the URI.""" + return extra + @classmethod def get_parameters_from_database( cls, diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 811ca9a4c5b6..6e6127794e83 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -209,7 +209,7 @@ class DatabricksPythonConnectorSchema(DatabricksBaseSchema): """ http_path_field = fields.Str(required=True) - default_catalog = fields.Str(required=True) + default_catalog = fields.Str(required=False) default_schema = fields.Str(required=True) @@ -841,7 +841,6 @@ class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec): } required_parameters = DatabricksDynamicBaseEngineSpec.required_parameters | { - "default_catalog", "default_schema", "http_path_field", } @@ -924,6 +923,29 @@ def get_parameters_from_database( parameters[field] = database.connect_args[key] return parameters + @classmethod + def get_extra_from_parameters( + cls, parameters: dict[str, Any], extra: str | None + ) -> str | None: + """Remove legacy overrides when a native form explicitly clears a field.""" + try: + settings = json.loads(extra or "{}") + connect_args = settings.get("engine_params", {}).get("connect_args", {}) + except (ValueError, AttributeError, TypeError): + return extra + if not isinstance(connect_args, dict): + return extra + changed = False + for field, key in ( + ("http_path_field", "http_path"), + ("default_catalog", "catalog"), + ("default_schema", "schema"), + ): + if field in parameters and not parameters[field] and key in connect_args: + del connect_args[key] + changed = True + return json.dumps(settings) if changed else extra + @classmethod def get_connection_identity(cls, uri: URL, connect_args: dict[str, Any]) -> URL: """ @@ -947,9 +969,14 @@ def get_default_catalog( cls, database: Database, ) -> str | None: - return database.url_object.query.get("catalog") or database.connect_args.get( + if catalog := database.url_object.query.get( "catalog" - ) + ) or database.connect_args.get("catalog"): + return catalog + # Converted rows retain the runtime catalog used by legacy permissions. + if database.get_extra().get("migrated_from_databricks_connector_uri"): + return DatabricksNativeEngineSpec.get_default_catalog(database) + return None @classmethod def get_catalog_names( @@ -999,7 +1026,7 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): properties_schema = DatabricksNativePropertiesSchema() sqlalchemy_uri_placeholder = ( - "databricks://token:{access_token}@{host}:{port}/{database_name}" + "databricks+connector://token:{access_token}@{host}:{port}/{database_name}" ) metadata = { @@ -1013,7 +1040,7 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): ], "pypi_packages": ["apache-superset[databricks]"], "connection_string": ( - "databricks://token:{access_token}@{host}:{port}/{database_name}?http_path={http_path}" + "databricks+connector://token:{access_token}@{host}:{port}/{database_name}?http_path={http_path}" ), "default_port": 443, "parameters": { @@ -1070,7 +1097,7 @@ def build_sqlalchemy_uri( # type: ignore # False) is required here since this URI is stored/used to actually # connect, not just displayed. return URL.create( - cls.engine, + f"{cls.engine}+{cls.default_driver}", username="token", password=parameters.get("access_token"), host=parameters["host"], diff --git a/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py b/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py index 978ba8d4b514..446aab2d758d 100644 --- a/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py +++ b/superset/migrations/versions/2026-10-29_00-00_b8f3a2c91d64_migrate_databricks_connector_uri.py @@ -124,15 +124,7 @@ def _migrate(upgrading: bool) -> None: if extra is not None and extra_marked: extra[MARKER] = True extra_text = json.dumps(extra) - # Preserve conflicting user settings instead of aborting the upgrade. - bind.execute(records.delete().where(records.c.id == row.id)) - bind.execute( - records.insert().values( - id=row.id, - fingerprint=_fingerprint(uri, extra_text, row.changed_on), - extra_marked=extra_marked, - ) - ) + else: unchanged = row.fingerprint == _fingerprint( uri, extra_text, row.changed_on @@ -152,11 +144,27 @@ def _migrate(upgrading: bool) -> None: ): del extra[MARKER] extra_text = json.dumps(extra) - bind.execute( + # Only replace the snapshot that this batch actually read. + updated = bind.execute( databases.update() - .where(databases.c.id == row.id) + .where( + databases.c.id == row.id, + databases.c.sqlalchemy_uri == row.sqlalchemy_uri, + databases.c.extra == row.extra, + databases.c.changed_on == row.changed_on, + ) .values(sqlalchemy_uri=uri, extra=extra_text) ) + if upgrading and updated.rowcount: + # Preserve conflicting user settings instead of aborting the upgrade. + bind.execute(records.delete().where(records.c.id == row.id)) + bind.execute( + records.insert().values( + id=row.id, + fingerprint=_fingerprint(uri, extra_text, row.changed_on), + extra_marked=extra_marked, + ) + ) def upgrade() -> None: diff --git a/superset/models/core.py b/superset/models/core.py index b771789e5a3e..da20575ed392 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -423,7 +423,11 @@ def backend(self) -> str: @property def driver(self) -> str: - return self.url_object.get_driver_name() + try: + return self.url_object.get_driver_name() + except NoSuchModuleError: + # Driverless connections must still open in the URI repair form. + return "" @property def masked_encrypted_extra(self) -> str | None: @@ -435,13 +439,12 @@ def parameters(self) -> dict[str, Any]: # the sqlalchemy_uri # When returning the parameters we should use the masked SQLAlchemy URI and the # masked ``encrypted_extra`` to prevent exposing sensitive credentials. - # An unparseable URI raises here rather than yielding empty parameters. - make_url_safe(self.sqlalchemy_uri) - encrypted_config = {} - if (masked_encrypted_extra := self.masked_encrypted_extra) is not None: - with suppress(TypeError, json.JSONDecodeError): - encrypted_config = json.loads(masked_encrypted_extra) try: + make_url_safe(self.sqlalchemy_uri) + encrypted_config = {} + if (masked_encrypted_extra := self.masked_encrypted_extra) is not None: + with suppress(TypeError, json.JSONDecodeError): + encrypted_config = json.loads(masked_encrypted_extra) parameters = self.db_engine_spec.get_parameters_from_database( self, encrypted_extra=encrypted_config, diff --git a/tests/unit_tests/commands/databases/validate_test.py b/tests/unit_tests/commands/databases/validate_test.py index 22c2a3315df2..12ad416aaa2c 100644 --- a/tests/unit_tests/commands/databases/validate_test.py +++ b/tests/unit_tests/commands/databases/validate_test.py @@ -222,6 +222,7 @@ def test_command_duplicate_database_name(mocker: MockerFixture) -> None: "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, ), ) @@ -259,6 +260,7 @@ def test_command_duplicate_database_name_on_update(mocker: MockerFixture) -> Non "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, ), ) @@ -339,6 +341,7 @@ def test_validate_ssh_tunnel_missing_db_port(mocker: MockerFixture) -> None: "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, ), ) @@ -373,6 +376,7 @@ def test_get_ssh_tunnel_errors_missing_required_fields( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, ), ) @@ -429,6 +433,7 @@ def test_get_ssh_tunnel_errors_unencrypted_private_key_is_valid( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"), unmask_encrypted_extra=mocker.MagicMock(return_value="{}"), ), @@ -479,6 +484,7 @@ def test_ssh_tunnel_forwarded_to_connection_test( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"), unmask_encrypted_extra=mocker.MagicMock(return_value="{}"), ), @@ -552,6 +558,7 @@ def test_ssh_tunnel_unmasked_when_only_engine_params_changed( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, # a fresh (non-masked) password, as if the user supplied the # real one for the main connection -- this is the legitimate # path that gets past the main URI-identity check below @@ -615,6 +622,7 @@ def test_get_ssh_tunnel_errors_skipped_when_parameters_ssh_false( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"), unmask_encrypted_extra=mocker.MagicMock(return_value="{}"), ), @@ -658,6 +666,7 @@ def test_get_ssh_tunnel_errors_skipped_when_not_enabled( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, build_sqlalchemy_uri=mocker.MagicMock(return_value="postgresql://"), unmask_encrypted_extra=mocker.MagicMock(return_value="{}"), ), @@ -758,6 +767,7 @@ def test_validate_engine_params_change_requires_new_credentials( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, build_sqlalchemy_uri=mocker.MagicMock( return_value="postgresql://u:XXXXXXXXXX@host1/d" ), @@ -823,6 +833,7 @@ def test_validate_ssh_tunnel_host_change_requires_new_credentials( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, build_sqlalchemy_uri=mocker.MagicMock( return_value="postgresql://u:XXXXXXXXXX@host1/d" ), @@ -868,6 +879,7 @@ def test_ssh_tunnel_missing_message_is_interpolated( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, ), ) @@ -905,7 +917,7 @@ def test_ssh_tunnel_missing_message_is_interpolated( "?catalog=main&http_path=%2Flegacy&schema=default", "databricks://token:real-token@host:443/default", ), - # a different warehouse keeps the masked token + # a different warehouse requires a freshly entered token ( "databricks://token:XXXXXXXXXX@host:443" "?catalog=main&http_path=%2Fother&schema=default", @@ -956,6 +968,7 @@ def test_validate_converted_databricks_connection_token( "superset.commands.database.validate.get_engine_spec", return_value=mocker.MagicMock( validate_parameters=mocker.MagicMock(return_value=[]), + get_extra_from_parameters=lambda parameters, extra: extra, build_sqlalchemy_uri=mocker.MagicMock(return_value=rebuilt_uri), unmask_encrypted_extra=mocker.MagicMock(return_value="{}"), ), @@ -968,6 +981,133 @@ def test_validate_converted_databricks_connection_token( "parameters": {"host": "host", "port": 443}, "extra": extra, } - ValidateDatabaseParametersCommand(properties).run() + if "XXXXXXXXXX" in expected_uri: + with pytest.raises(InvalidParametersError, match="Re-enter the access token"): + ValidateDatabaseParametersCommand(properties).run() + database.set_sqlalchemy_uri.assert_not_called() + else: + ValidateDatabaseParametersCommand(properties).run() + database.set_sqlalchemy_uri.assert_called_once_with(expected_uri) - database.set_sqlalchemy_uri.assert_called_once_with(expected_uri) + +def test_validate_converted_databricks_without_catalog(mocker: MockerFixture) -> None: + """An unchanged converted row uses real field validation and its stored token.""" + from superset.models.core import Database + + extra = json.dumps( + { + "migrated_from_databricks_connector_uri": True, + "engine_params": {"connect_args": {"http_path": "/legacy"}}, + } + ) + existing = Database(extra=extra) + existing.set_sqlalchemy_uri("databricks://token:test-token@host:443/default") + mocker.patch( + "superset.commands.database.validate.DatabaseDAO.find_by_id", + return_value=existing, + ) + mocker.patch( + "superset.db_engine_specs.databricks.is_hostname_valid", return_value=True + ) + mocker.patch("superset.db_engine_specs.databricks.is_port_open", return_value=True) + database = mocker.MagicMock() + with database.get_sqla_engine() as engine: + engine.dialect.do_ping.return_value = True + mocker.patch( + "superset.commands.database.validate.DatabaseDAO.build_db_for_connection_test", + return_value=database, + ) + ValidateDatabaseParametersCommand( + { + "id": 1, + "engine": "databricks", + "driver": "databricks-sql-python", + "parameters": existing.parameters, + "extra": extra, + } + ).run() + database.set_sqlalchemy_uri.assert_called_once_with( + existing.sqlalchemy_uri_decrypted + ) + + +@pytest.mark.parametrize( + "field, value", + [("host", "other-host"), ("port", 444), ("http_path_field", "/other")], +) +def test_validate_databricks_destination_change_requires_fresh_token( + mocker: MockerFixture, + field: str, + value: str | int, +) -> None: + """Never send a password mask to a changed Databricks destination.""" + from superset.models.core import Database + + existing = Database( + extra='{"engine_params":{"connect_args":{"http_path":"/legacy"}}}' + ) + existing.set_sqlalchemy_uri("databricks://token:test-token@host:443/default") + mocker.patch( + "superset.commands.database.validate.DatabaseDAO.find_by_id", + return_value=existing, + ) + mocker.patch( + "superset.db_engine_specs.databricks.is_hostname_valid", return_value=True + ) + mocker.patch("superset.db_engine_specs.databricks.is_port_open", return_value=True) + build = mocker.patch( + "superset.commands.database.validate.DatabaseDAO.build_db_for_connection_test" + ) + parameters = {**existing.parameters, field: value} + with pytest.raises(InvalidParametersError, match="Re-enter the access token"): + ValidateDatabaseParametersCommand( + { + "id": 1, + "engine": "databricks", + "driver": "databricks-sql-python", + "parameters": parameters, + "extra": existing.extra, + } + ).run() + build.assert_not_called() + + +def test_validate_legacy_databricks_form_keeps_masked_token( + mocker: MockerFixture, +) -> None: + """An unconverted saved form preserves its driver and stored credential.""" + from superset.models.core import Database + + existing = Database( + extra='{"engine_params":{"connect_args":{"http_path":"/legacy"}}}' + ) + existing.set_sqlalchemy_uri( + "databricks+connector://token:test-token@host:443/default" + ) + mocker.patch( + "superset.commands.database.validate.DatabaseDAO.find_by_id", + return_value=existing, + ) + mocker.patch( + "superset.db_engine_specs.databricks.is_hostname_valid", return_value=True + ) + mocker.patch("superset.db_engine_specs.databricks.is_port_open", return_value=True) + database = mocker.MagicMock() + with database.get_sqla_engine() as engine: + engine.dialect.do_ping.return_value = True + mocker.patch( + "superset.commands.database.validate.DatabaseDAO.build_db_for_connection_test", + return_value=database, + ) + ValidateDatabaseParametersCommand( + { + "id": 1, + "engine": "databricks", + "driver": "connector", + "parameters": existing.parameters, + "extra": existing.extra, + } + ).run() + database.set_sqlalchemy_uri.assert_called_once_with( + existing.sqlalchemy_uri_decrypted + ) diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py index b5e7b488cd66..e61ce759855c 100644 --- a/tests/unit_tests/databases/api_test.py +++ b/tests/unit_tests/databases/api_test.py @@ -3044,3 +3044,26 @@ def test_related_objects_limits_dataset_details( assert payload["count"] == MAX_RELATED_DATASETS + 2 assert len(payload["result"]) == MAX_RELATED_DATASETS assert can_access.call_count == MAX_RELATED_DATASETS + + +def test_available_hides_legacy_databricks( + mocker: MockerFixture, + client: FlaskClient, + full_api_access: None, +) -> None: + """New connections offer the supported Databricks form, not the legacy one.""" + from superset.db_engine_specs.databricks import ( + DatabricksNativeEngineSpec, + DatabricksPythonConnectorEngineSpec, + ) + + mocker.patch( + "superset.databases.api.get_available_engine_specs", + return_value={ + DatabricksNativeEngineSpec: {"connector"}, + DatabricksPythonConnectorEngineSpec: {"databricks-sql-python"}, + }, + ) + response = client.get("/api/v1/database/available/") + assert response.status_code == 200 + assert [item["name"] for item in response.json["databases"]] == ["Databricks"] diff --git a/tests/unit_tests/databases/schema_tests.py b/tests/unit_tests/databases/schema_tests.py index 3024654f52c7..876bc56971c9 100644 --- a/tests/unit_tests/databases/schema_tests.py +++ b/tests/unit_tests/databases/schema_tests.py @@ -800,3 +800,39 @@ def test_validate_parameters_schema_accepts_server_host_key() -> None: loaded["ssh_tunnel"]["server_host_key"] == "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA" ) + + +@pytest.mark.parametrize("schema_name", ["DatabasePostSchema", "DatabasePutSchema"]) +def test_databricks_form_clear_catalog_removes_legacy_override( + schema_name: str, +) -> None: + """Both create and update normalize cleared fields before persisting extra.""" + from superset.databases import schemas + + schema = getattr(schemas, schema_name)() + payload = schema.load( + { + "database_name": "Databricks", + "configuration_method": "dynamic_form", + "engine": "databricks", + "driver": "databricks-sql-python", + "parameters": { + "host": "host", + "port": 443, + "access_token": "test-token", + "default_schema": "default", + "http_path_field": "/legacy", + "default_catalog": "", + }, + "extra": json.dumps( + { + "engine_params": { + "connect_args": {"catalog": "old", "http_path": "/legacy"} + } + } + ), + } + ) + args = json.loads(payload["extra"])["engine_params"]["connect_args"] + assert "catalog" not in args + assert args["http_path"] == "/legacy" diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 108b2989dd4f..3dc75f29089e 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -19,7 +19,7 @@ from datetime import datetime from importlib import import_module from importlib.metadata import PackageNotFoundError, requires -from typing import Any, cast, Optional +from typing import Any, cast, Literal, Optional from unittest.mock import MagicMock from urllib.parse import parse_qs, urlparse @@ -137,7 +137,9 @@ def test_build_sqlalchemy_uri() -> None: sqlalchemy_uri = DatabricksNativeEngineSpec.build_sqlalchemy_uri( parameters, encrypted_extra ) - assert sqlalchemy_uri == "databricks://token:abc12345@my_hostname:1234/test" + assert ( + sqlalchemy_uri == "databricks+connector://token:abc12345@my_hostname:1234/test" + ) def test_parameters_json_schema() -> None: @@ -1755,3 +1757,92 @@ def test_connection_identity_matches_legacy_settings_and_native_query() -> None: assert BaseEngineSpec.get_connection_identity( make_url("postgresql://u@host/db"), {"host": "other"} ) == make_url("postgresql://u@host/db") + + +@pytest.mark.parametrize("catalogs", [[("hive_metastore",)], [("main",), ("other",)]]) +def test_migrated_default_catalog_preserves_legacy_runtime_default( + mocker: MockerFixture, catalogs: list[tuple[str]] +) -> None: + """Converted rows keep the catalog used by their dataset permissions.""" + from superset.models.core import Database + + database = Database( + sqlalchemy_uri="databricks://token:XXXXXXXXXX@host:443/default", + extra=json.dumps( + { + "migrated_from_databricks_connector_uri": True, + "engine_params": {"connect_args": {"http_path": "/legacy"}}, + } + ), + ) + engine = mocker.MagicMock() + connection = ( + engine.__enter__.return_value.connect.return_value.__enter__.return_value + ) + result = mocker.MagicMock() + result.scalar.return_value = "hive_metastore" + connection.execute.side_effect = [catalogs, result] + mocker.patch.object(database, "get_sqla_engine", return_value=engine) + assert ( + DatabricksPythonConnectorEngineSpec.get_default_catalog(database) + == "hive_metastore" + ) + assert connection.execute.call_count == (1 if len(catalogs) == 1 else 2) + database.extra = "{}" + assert DatabricksPythonConnectorEngineSpec.get_default_catalog(database) is None + + +def test_native_catalog_is_optional_in_form_schema() -> None: + """The browser must not require a catalog that legacy forms never collected.""" + schema = DatabricksPythonConnectorEngineSpec.parameters_json_schema() + assert "default_catalog" not in schema["required"] + + +@pytest.mark.parametrize( + "field, key", + [ + ("default_catalog", "catalog"), + ("default_schema", "schema"), + ("http_path_field", "http_path"), + ], +) +def test_native_form_clear_overrides_legacy_connect_args( + field: Literal["default_catalog", "default_schema", "http_path_field"], key: str +) -> None: + """Clearing a field removes its old connect_args value at connection time.""" + parameters = DatabricksPythonConnectorParametersType( + host="host", + port=443, + access_token="test-token", # noqa: S106 + default_catalog="main", + default_schema="default", + http_path_field="/legacy", + encryption=False, + ) + parameters[field] = "" + uri = make_url(DatabricksPythonConnectorEngineSpec.build_sqlalchemy_uri(parameters)) + extra = DatabricksPythonConnectorEngineSpec.get_extra_from_parameters( + dict(parameters), + json.dumps({"engine_params": {"connect_args": {key: "old", "other": "kept"}}}), + ) + assert extra is not None + adjusted, args = DatabricksPythonConnectorEngineSpec.adjust_engine_params( + uri, json.loads(extra)["engine_params"]["connect_args"] + ) + assert args["other"] == "kept" + assert key not in args + assert key not in adjusted.query + + +def test_legacy_form_preserves_connector_scheme() -> None: + """Unconverted forms must not silently change the credential destination.""" + from superset.commands.database.utils import uri_identity_changed + + parameters = DatabricksNativeEngineSpec.get_parameters_from_uri( + "databricks+connector://token:XXXXXXXXXX@host:443/default" + ) + rebuilt = DatabricksNativeEngineSpec.build_sqlalchemy_uri(parameters) + assert rebuilt.startswith("databricks+connector://") + assert not uri_identity_changed( + "databricks+connector://token:XXXXXXXXXX@host:443/default", rebuilt + ) diff --git a/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py index 21ba00bae6c8..79b047a834ee 100644 --- a/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py +++ b/tests/unit_tests/migrations/test_migrate_databricks_connector_uri.py @@ -18,7 +18,8 @@ from collections.abc import Iterator from importlib import import_module -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch import pytest import sqlalchemy as sa @@ -360,3 +361,57 @@ def test_downgrade_imported_marker_and_unmarked_native_rows(engine: Engine) -> N row.sqlalchemy_uri == NATIVE + MASKED_SUFFIX for row in conn.execute(sa.select(table)) ) + + +@pytest.mark.parametrize("upgrading", [True, False]) +def test_migration_preserves_edit_after_batch_read( + engine: Engine, upgrading: bool +) -> None: + """A concurrent edit must win over the batch's stale connection settings.""" + table = migration.databases + with engine.begin() as conn: + conn.execute( + table.insert().values( + id=1, sqlalchemy_uri=LEGACY + MASKED_SUFFIX, extra="{}" + ) + ) + if not upgrading: + _run(engine, True) + with engine.begin() as conn: + original_execute = conn.execute + edited = False + + def execute( + statement: sa.sql.Executable, *args: object, **kwargs: object + ) -> Any: + """Materialize a batch, then edit the row before the migration writes.""" + nonlocal edited + result = original_execute(statement, *args, **kwargs) + if isinstance(statement, sa.sql.Select) and not edited: + rows = result.fetchall() + original_execute( + table.update() + .where(table.c.id == 1) + .values( + sqlalchemy_uri="postgresql://edited/db", + extra='{"edited":true}', + ) + ) + edited = True + result = MagicMock() + result.fetchall.return_value = rows + return result + + with ( + Operations.context(MigrationContext.configure(conn)), + patch.object(conn, "execute", side_effect=execute), + ): + if upgrading: + migration.upgrade() + else: + migration.downgrade() + row = original_execute(sa.select(table)).one() + assert row.sqlalchemy_uri == "postgresql://edited/db" + assert row.extra == '{"edited":true}' + if upgrading: + assert original_execute(sa.select(migration.records)).fetchall() == [] diff --git a/tests/unit_tests/models/core_test.py b/tests/unit_tests/models/core_test.py index b7e488a50e25..f87c044268e2 100644 --- a/tests/unit_tests/models/core_test.py +++ b/tests/unit_tests/models/core_test.py @@ -40,7 +40,6 @@ from sqlalchemy.orm.session import Session from sqlalchemy.sql import Select -from superset.commands.database.exceptions import DatabaseInvalidError from superset.connectors.sqla.models import SqlaTable, TableColumn from superset.databases.error_provenance import mark_database_engine_error from superset.errors import SupersetErrorType @@ -289,17 +288,23 @@ def test_dttm_sql_literal( assert SqlaTable(database=database).dttm_sql_literal(dttm, col) == result -def test_parameters_rejects_invalid_uri(app_context: None) -> None: - """ - Test that an unparseable saved URI still raises instead of returning no parameters. - """ - database = Database( - database_name="db", - sqlalchemy_uri="postgresql://user:pass@host:notaport/db", - ) - - with pytest.raises(DatabaseInvalidError): - _ = database.parameters +@pytest.mark.parametrize( + "uri", + [ + "postgresql://user:pass@host:notaport/db", + "not a SQLAlchemy URI", + "uninstalled_dialect://host/db", + ], +) +def test_connection_details_allow_uri_repair(app_context: None, uri: str) -> None: + """Broken saved connections serialize enough information for the URI editor.""" + from superset.databases.schemas import DatabaseConnectionSchema + + database = Database(database_name="db", sqlalchemy_uri=uri, extra="{}") + result = DatabaseConnectionSchema().dump(database) + assert result["parameters"] == {} + assert result["parameters_schema"] == {} + assert result["sqlalchemy_uri"] == uri def test_table_column_database() -> None: From dde73b1b0495a51bcf0784abb23849e0127cbc79 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 29 Oct 2026 20:43:41 +0000 Subject: [PATCH 07/12] fix(i18n): include Databricks token validation message --- superset/translations/messages.pot | 3 +++ 1 file changed, 3 insertions(+) diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index a5f9df9eb159..5f04cd7664b7 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -11095,6 +11095,9 @@ msgstr "" msgid "Raw records" msgstr "" +msgid "Re-enter the access token when changing the connection destination." +msgstr "" + msgid "Rearranged layout" msgstr "" From f875d424a8f02311fd263fe1fcd39425a17c0155 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 30 Oct 2026 23:20:55 +0000 Subject: [PATCH 08/12] fix(databases): avoid restoring closed edits into new connections --- .../databases/supported/databricks.mdx | 4 + .../databases/DatabaseModal/index.test.tsx | 95 +++++++++++++++++++ .../databases/DatabaseModal/index.tsx | 3 +- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx index 8d8e2a11df1c..5477d29cfce6 100644 --- a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx +++ b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx @@ -156,3 +156,7 @@ connection destination requires re-entering the access token. The new-connection picker offers **Databricks**, not **Databricks (legacy)**. Existing legacy connections can still be edited with their saved form. + +To create a separate connection after editing one, close the edit dialog and +choose **+ Database**. The database picker starts a new connection without +reusing the edited connection's name or URI. diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 7f9d720c1414..433ec2acb4dc 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -19,6 +19,7 @@ // TODO: These tests should be made atomic in separate files +import { useState } from 'react'; import fetchMock from 'fetch-mock'; import { render, @@ -338,6 +339,100 @@ describe('DatabaseModal', () => { useRedux: true, }); + test('closing edit then creating a database starts at the picker and never updates the old connection', async () => { + const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); + const previousImplementation = resourceHook.getMockImplementation()!; + resourceHook.mockImplementation(realUseSingleViewResource); + fetchMock.get('glob:*/api/v1/database/10/connection', { + result: { + id: 10, + database_name: 'Existing connection', + backend: 'sqlite', + configuration_method: ConfigurationMethod.SqlalchemyUri, + sqlalchemy_uri: 'sqlite:///existing.db', + }, + }); + fetchMock.put(DATABASE_FETCH_ENDPOINT, { result: { id: 10 } }); + fetchMock.post('glob:*/api/v1/database/test_connection/', { + message: 'OK', + }); + const Harness = () => { + const [show, setShow] = useState(true); + const [databaseId, setDatabaseId] = useState(10); + return ( + <> + + { + setShow(false); + setDatabaseId(undefined); + }} + /> + + ); + }; + try { + render(, { useRedux: true }); + expect( + await screen.findByDisplayValue('Existing connection'), + ).toBeInTheDocument(); + await userEvent.click(screen.getAllByLabelText('Close')[0]); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + await userEvent.click(screen.getByRole('button', { name: '+ Database' })); + await screen.findByRole('dialog'); + const picker = screen.queryByRole('heading', { + name: /select a database to connect/i, + }); + // Exercise saving even if stale state incorrectly skips the picker. + if (picker) { + await userEvent.click( + await screen.findByRole('button', { name: /sqlite/i }), + ); + } + fireEvent.change(await screen.findByTestId('database-name-input'), { + target: { value: 'My new sandbox' }, + }); + fireEvent.change(screen.getByTestId('sqlalchemy-uri-input'), { + target: { value: 'sqlite:///sandbox.db' }, + }); + await userEvent.click( + screen.getByRole('button', { name: /test connection/i }), + ); + const connect = screen.getByRole('button', { name: 'Connect' }); + await waitFor(() => expect(connect).toBeEnabled()); + await userEvent.click(connect); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT).length + + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { + method: 'PUT', + }).length, + ).toBeGreaterThan(0), + ); + expect( + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { method: 'PUT' }), + ).toHaveLength(0); + expect(picker).not.toBeNull(); + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), + ).toHaveLength(1); + expect( + JSON.parse( + fetchMock.callHistory.lastCall(DATABASE_CONNECT_ENDPOINT)!.options + .body as string, + ), + ).not.toHaveProperty('id'); + } finally { + resourceHook.mockImplementation(previousImplementation); + } + }); + // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('Visual: New database connection', () => { test('renders the initial load of Step 1 correctly', async () => { diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index bda0d68a338c..9c2ae2a10200 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -1547,7 +1547,8 @@ const DatabaseModal: FunctionComponent = ({ // because it shouldn't change when the form is updated setDbName(dbFetched.database_name); } - }, [dbFetched, isEditMode]); + // Apply each fetched resource once; mode changes must not restore a closed edit. + }, [dbFetched]); useEffect(() => { if (isLoading) { From 8e73ee07c14d4e85c472072cd9642b061409f9b0 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 31 Oct 2026 01:24:19 +0000 Subject: [PATCH 09/12] fix(databases): ignore cached resource updates after modal close --- .../databases/DatabaseModal/index.test.tsx | 231 +++++++++++------- .../databases/DatabaseModal/index.tsx | 5 +- 2 files changed, 146 insertions(+), 90 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 433ec2acb4dc..e4105c57488f 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -339,99 +339,154 @@ describe('DatabaseModal', () => { useRedux: true, }); - test('closing edit then creating a database starts at the picker and never updates the old connection', async () => { - const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); - const previousImplementation = resourceHook.getMockImplementation()!; - resourceHook.mockImplementation(realUseSingleViewResource); - fetchMock.get('glob:*/api/v1/database/10/connection', { - result: { + test.each(['Close', 'X', 'Finish'])( + '%s edit then creating a database starts at the picker and never updates the old connection', + async closeAction => { + const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); + const previousImplementation = resourceHook.getMockImplementation()!; + resourceHook.mockImplementation(realUseSingleViewResource); + fetchMock.get('glob:*/api/v1/database/10/connection', { + result: { + id: 10, + database_name: 'Existing connection', + backend: 'sqlite', + configuration_method: ConfigurationMethod.SqlalchemyUri, + sqlalchemy_uri: 'sqlite:///existing.db', + }, + }); + fetchMock.put(DATABASE_FETCH_ENDPOINT, { id: 10, - database_name: 'Existing connection', - backend: 'sqlite', - configuration_method: ConfigurationMethod.SqlalchemyUri, - sqlalchemy_uri: 'sqlite:///existing.db', - }, - }); - fetchMock.put(DATABASE_FETCH_ENDPOINT, { result: { id: 10 } }); - fetchMock.post('glob:*/api/v1/database/test_connection/', { - message: 'OK', - }); - const Harness = () => { - const [show, setShow] = useState(true); - const [databaseId, setDatabaseId] = useState(10); - return ( - <> - - { - setShow(false); - setDatabaseId(undefined); - }} - /> - - ); - }; - try { - render(, { useRedux: true }); - expect( - await screen.findByDisplayValue('Existing connection'), - ).toBeInTheDocument(); - await userEvent.click(screen.getAllByLabelText('Close')[0]); - await waitFor(() => - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), - ); - await userEvent.click(screen.getByRole('button', { name: '+ Database' })); - await screen.findByRole('dialog'); - const picker = screen.queryByRole('heading', { - name: /select a database to connect/i, + result: { + database_name: 'Existing connection', + backend: 'sqlite', + configuration_method: ConfigurationMethod.SqlalchemyUri, + sqlalchemy_uri: 'sqlite:///existing.db', + }, + }); + fetchMock.post('glob:*/api/v1/database/test_connection/', { + message: 'OK', }); - // Exercise saving even if stale state incorrectly skips the picker. - if (picker) { + const Harness = () => { + const [show, setShow] = useState(true); + const [databaseId, setDatabaseId] = useState(10); + return ( + <> + + + { + setShow(false); + setDatabaseId(undefined); + }} + /> + + ); + }; + try { + render(, { useRedux: true }); + expect( + await screen.findByDisplayValue('Existing connection'), + ).toBeInTheDocument(); + await userEvent.click(document.querySelector('.ant-modal-wrap')!); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + if (closeAction === 'Finish') { + await userEvent.click(screen.getByRole('button', { name: 'Finish' })); + } else if (closeAction === 'X') { + await userEvent.click(screen.getAllByLabelText('Close')[0]); + } else { + await userEvent.click(screen.getByText('Close')); + } + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + fetchMock.clearHistory(); await userEvent.click( - await screen.findByRole('button', { name: /sqlite/i }), + screen.getByRole('button', { name: '+ Database' }), + ); + await screen.findByRole('dialog'); + const picker = screen.queryByRole('heading', { + name: /select a database to connect/i, + }); + // Exercise saving even if stale state incorrectly skips the picker. + if (picker) { + await userEvent.click( + await screen.findByRole('button', { name: /sqlite/i }), + ); + } + fireEvent.change(await screen.findByTestId('database-name-input'), { + target: { value: 'My new sandbox' }, + }); + fireEvent.change(screen.getByTestId('sqlalchemy-uri-input'), { + target: { value: 'sqlite:///sandbox.db' }, + }); + await userEvent.click( + screen.getByRole('button', { name: /test connection/i }), + ); + const connect = screen.getByRole('button', { name: 'Connect' }); + await waitFor(() => expect(connect).toBeEnabled()); + await userEvent.click(connect); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT).length + + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { + method: 'PUT', + }).length, + ).toBeGreaterThan(0), ); - } - fireEvent.change(await screen.findByTestId('database-name-input'), { - target: { value: 'My new sandbox' }, - }); - fireEvent.change(screen.getByTestId('sqlalchemy-uri-input'), { - target: { value: 'sqlite:///sandbox.db' }, - }); - await userEvent.click( - screen.getByRole('button', { name: /test connection/i }), - ); - const connect = screen.getByRole('button', { name: 'Connect' }); - await waitFor(() => expect(connect).toBeEnabled()); - await userEvent.click(connect); - await waitFor(() => expect( - fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT).length + - fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { - method: 'PUT', - }).length, - ).toBeGreaterThan(0), - ); - expect( - fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { method: 'PUT' }), - ).toHaveLength(0); - expect(picker).not.toBeNull(); - expect( - fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), - ).toHaveLength(1); - expect( - JSON.parse( - fetchMock.callHistory.lastCall(DATABASE_CONNECT_ENDPOINT)!.options - .body as string, - ), - ).not.toHaveProperty('id'); - } finally { - resourceHook.mockImplementation(previousImplementation); - } - }); + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { + method: 'PUT', + }), + ).toHaveLength(0); + expect(picker).not.toBeNull(); + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), + ).toHaveLength(1); + expect( + JSON.parse( + fetchMock.callHistory.lastCall(DATABASE_CONNECT_ENDPOINT)!.options + .body as string, + ), + ).not.toHaveProperty('id'); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + // A successful create also updates the resource cache before closing. + await userEvent.click( + screen.getByRole('button', { name: '+ Database' }), + ); + expect( + await screen.findByRole('heading', { + name: /select a database to connect/i, + }), + ).toBeInTheDocument(); + await userEvent.click(screen.getAllByLabelText('Close')[0]); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Edit existing' }), + ); + expect( + await screen.findByDisplayValue('Existing connection'), + ).toBeInTheDocument(); + } finally { + resourceHook.mockImplementation(previousImplementation); + } + }, + ); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('Visual: New database connection', () => { diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 9c2ae2a10200..5b8ef34e2186 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -1537,7 +1537,7 @@ const DatabaseModal: FunctionComponent = ({ }, [show, databaseId]); useEffect(() => { - if (dbFetched) { + if (show && dbFetched && (!databaseId || dbFetched.id === databaseId)) { setDB({ type: ActionType.Fetched, isEditMode, @@ -1547,7 +1547,8 @@ const DatabaseModal: FunctionComponent = ({ // because it shouldn't change when the form is updated setDbName(dbFetched.database_name); } - // Apply each fetched resource once; mode changes must not restore a closed edit. + // Apply resource changes only while open, including multi-step creates. + // Opening the modal must not reapply a cached record after onClose resets it. }, [dbFetched]); useEffect(() => { From 8271d43868fd2001d5f52f0fdb109bd74d8f321a Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 31 Oct 2026 01:26:35 +0000 Subject: [PATCH 10/12] test(databases): name modal lifecycle regressions explicitly --- .../databases/DatabaseModal/index.test.tsx | 294 +++++++++--------- 1 file changed, 151 insertions(+), 143 deletions(-) diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index e4105c57488f..02122157b7d5 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -339,154 +339,162 @@ describe('DatabaseModal', () => { useRedux: true, }); - test.each(['Close', 'X', 'Finish'])( - '%s edit then creating a database starts at the picker and never updates the old connection', - async closeAction => { - const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); - const previousImplementation = resourceHook.getMockImplementation()!; - resourceHook.mockImplementation(realUseSingleViewResource); - fetchMock.get('glob:*/api/v1/database/10/connection', { - result: { - id: 10, - database_name: 'Existing connection', - backend: 'sqlite', - configuration_method: ConfigurationMethod.SqlalchemyUri, - sqlalchemy_uri: 'sqlite:///existing.db', - }, - }); - fetchMock.put(DATABASE_FETCH_ENDPOINT, { + /** Check that closing a mounted modal cannot reuse a saved connection id. */ + const expectFreshCreateAfterEdit = async ( + closeAction: 'Close' | 'X' | 'Finish', + ): Promise => { + const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); + const previousImplementation = resourceHook.getMockImplementation()!; + resourceHook.mockImplementation(realUseSingleViewResource); + fetchMock.get('glob:*/api/v1/database/10/connection', { + result: { id: 10, - result: { - database_name: 'Existing connection', - backend: 'sqlite', - configuration_method: ConfigurationMethod.SqlalchemyUri, - sqlalchemy_uri: 'sqlite:///existing.db', - }, - }); - fetchMock.post('glob:*/api/v1/database/test_connection/', { - message: 'OK', - }); - const Harness = () => { - const [show, setShow] = useState(true); - const [databaseId, setDatabaseId] = useState(10); - return ( - <> - - - { - setShow(false); - setDatabaseId(undefined); - }} - /> - - ); - }; - try { - render(, { useRedux: true }); - expect( - await screen.findByDisplayValue('Existing connection'), - ).toBeInTheDocument(); - await userEvent.click(document.querySelector('.ant-modal-wrap')!); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - if (closeAction === 'Finish') { - await userEvent.click(screen.getByRole('button', { name: 'Finish' })); - } else if (closeAction === 'X') { - await userEvent.click(screen.getAllByLabelText('Close')[0]); - } else { - await userEvent.click(screen.getByText('Close')); - } - await waitFor(() => - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), - ); - fetchMock.clearHistory(); - await userEvent.click( - screen.getByRole('button', { name: '+ Database' }), - ); - await screen.findByRole('dialog'); - const picker = screen.queryByRole('heading', { - name: /select a database to connect/i, - }); - // Exercise saving even if stale state incorrectly skips the picker. - if (picker) { - await userEvent.click( - await screen.findByRole('button', { name: /sqlite/i }), - ); - } - fireEvent.change(await screen.findByTestId('database-name-input'), { - target: { value: 'My new sandbox' }, - }); - fireEvent.change(screen.getByTestId('sqlalchemy-uri-input'), { - target: { value: 'sqlite:///sandbox.db' }, - }); - await userEvent.click( - screen.getByRole('button', { name: /test connection/i }), - ); - const connect = screen.getByRole('button', { name: 'Connect' }); - await waitFor(() => expect(connect).toBeEnabled()); - await userEvent.click(connect); - await waitFor(() => - expect( - fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT).length + - fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { - method: 'PUT', - }).length, - ).toBeGreaterThan(0), - ); - expect( - fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { - method: 'PUT', - }), - ).toHaveLength(0); - expect(picker).not.toBeNull(); - expect( - fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), - ).toHaveLength(1); - expect( - JSON.parse( - fetchMock.callHistory.lastCall(DATABASE_CONNECT_ENDPOINT)!.options - .body as string, - ), - ).not.toHaveProperty('id'); - await waitFor(() => - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), - ); - // A successful create also updates the resource cache before closing. - await userEvent.click( - screen.getByRole('button', { name: '+ Database' }), - ); - expect( - await screen.findByRole('heading', { - name: /select a database to connect/i, - }), - ).toBeInTheDocument(); + database_name: 'Existing connection', + backend: 'sqlite', + configuration_method: ConfigurationMethod.SqlalchemyUri, + sqlalchemy_uri: 'sqlite:///existing.db', + }, + }); + fetchMock.put(DATABASE_FETCH_ENDPOINT, { + id: 10, + result: { + database_name: 'Existing connection', + backend: 'sqlite', + configuration_method: ConfigurationMethod.SqlalchemyUri, + sqlalchemy_uri: 'sqlite:///existing.db', + }, + }); + fetchMock.post('glob:*/api/v1/database/test_connection/', { + message: 'OK', + }); + const Harness = () => { + const [show, setShow] = useState(true); + const [databaseId, setDatabaseId] = useState(10); + return ( + <> + + + { + setShow(false); + setDatabaseId(undefined); + }} + /> + + ); + }; + try { + render(, { useRedux: true }); + expect( + await screen.findByDisplayValue('Existing connection'), + ).toBeInTheDocument(); + await userEvent.click(document.querySelector('.ant-modal-wrap')!); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + if (closeAction === 'Finish') { + await userEvent.click(screen.getByRole('button', { name: 'Finish' })); + } else if (closeAction === 'X') { await userEvent.click(screen.getAllByLabelText('Close')[0]); - await waitFor(() => - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), - ); + } else { + await userEvent.click(screen.getByText('Close')); + } + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + fetchMock.clearHistory(); + await userEvent.click(screen.getByRole('button', { name: '+ Database' })); + await screen.findByRole('dialog'); + const picker = screen.queryByRole('heading', { + name: /select a database to connect/i, + }); + // Exercise saving even if stale state incorrectly skips the picker. + if (picker) { await userEvent.click( - screen.getByRole('button', { name: 'Edit existing' }), + await screen.findByRole('button', { name: /sqlite/i }), ); - expect( - await screen.findByDisplayValue('Existing connection'), - ).toBeInTheDocument(); - } finally { - resourceHook.mockImplementation(previousImplementation); } - }, - ); + fireEvent.change(await screen.findByTestId('database-name-input'), { + target: { value: 'My new sandbox' }, + }); + fireEvent.change(screen.getByTestId('sqlalchemy-uri-input'), { + target: { value: 'sqlite:///sandbox.db' }, + }); + await userEvent.click( + screen.getByRole('button', { name: /test connection/i }), + ); + const connect = screen.getByRole('button', { name: 'Connect' }); + await waitFor(() => expect(connect).toBeEnabled()); + await userEvent.click(connect); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT).length + + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { + method: 'PUT', + }).length, + ).toBeGreaterThan(0), + ); + expect( + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT, { + method: 'PUT', + }), + ).toHaveLength(0); + expect(picker).not.toBeNull(); + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), + ).toHaveLength(1); + expect( + JSON.parse( + fetchMock.callHistory.lastCall(DATABASE_CONNECT_ENDPOINT)!.options + .body as string, + ), + ).not.toHaveProperty('id'); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + // A successful create also updates the resource cache before closing. + await userEvent.click(screen.getByRole('button', { name: '+ Database' })); + expect( + await screen.findByRole('heading', { + name: /select a database to connect/i, + }), + ).toBeInTheDocument(); + await userEvent.click(screen.getAllByLabelText('Close')[0]); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Edit existing' }), + ); + expect( + await screen.findByDisplayValue('Existing connection'), + ).toBeInTheDocument(); + } finally { + resourceHook.mockImplementation(previousImplementation); + } + }; + + test('closing edit then creating uses POST without the old id', async () => { + await expectFreshCreateAfterEdit('Close'); + }); + + test('X edit then creating uses POST without the old id', async () => { + await expectFreshCreateAfterEdit('X'); + }); + + test('saving edit then creating uses POST without the old id', async () => { + await expectFreshCreateAfterEdit('Finish'); + }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('Visual: New database connection', () => { From eac7fed0f320b5c99547dab6c1b896af8bfc7329 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 31 Oct 2026 04:20:54 +0000 Subject: [PATCH 11/12] fix(databases): discard stale modal connection fetches --- .../databases/supported/databricks.mdx | 3 +- .../databases/DatabaseModal/index.test.tsx | 265 +++++++++++++----- .../databases/DatabaseModal/index.tsx | 39 ++- .../src/views/CRUD/hooks.test.tsx | 46 +++ superset-frontend/src/views/CRUD/hooks.ts | 13 +- 5 files changed, 284 insertions(+), 82 deletions(-) diff --git a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx index 5477d29cfce6..3a4ac335ad37 100644 --- a/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx +++ b/docs/user_docs_versioned_docs/version-6.1.0/databases/supported/databricks.mdx @@ -159,4 +159,5 @@ Existing legacy connections can still be edited with their saved form. To create a separate connection after editing one, close the edit dialog and choose **+ Database**. The database picker starts a new connection without -reusing the edited connection's name or URI. +reusing the edited connection's name or URI, even if the previous connection +was still loading when the edit dialog was closed. diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 02122157b7d5..9f6f88f2748c 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -28,6 +28,7 @@ import { within, waitFor, fireEvent, + act, } from 'spec/helpers/testing-library'; import { getExtensionsRegistry } from '@superset-ui/core'; import setupCodeOverrides from 'src/setup/setupCodeOverrides'; @@ -341,12 +342,16 @@ describe('DatabaseModal', () => { /** Check that closing a mounted modal cannot reuse a saved connection id. */ const expectFreshCreateAfterEdit = async ( - closeAction: 'Close' | 'X' | 'Finish', + closeAction: 'Close' | 'X' | 'Finish' | 'Late X', ): Promise => { const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); const previousImplementation = resourceHook.getMockImplementation()!; resourceHook.mockImplementation(realUseSingleViewResource); - fetchMock.get('glob:*/api/v1/database/10/connection', { + let releaseEditFetch: () => void = () => {}; + const editFetchReady = new Promise(resolve => { + releaseEditFetch = resolve; + }); + const editResponse = { result: { id: 10, database_name: 'Existing connection', @@ -354,7 +359,16 @@ describe('DatabaseModal', () => { configuration_method: ConfigurationMethod.SqlalchemyUri, sqlalchemy_uri: 'sqlite:///existing.db', }, - }); + }; + fetchMock.get( + 'glob:*/api/v1/database/10/connection', + closeAction === 'Late X' + ? async () => { + await editFetchReady; + return editResponse; + } + : editResponse, + ); fetchMock.put(DATABASE_FETCH_ENDPOINT, { id: 10, result: { @@ -397,14 +411,22 @@ describe('DatabaseModal', () => { }; try { render(, { useRedux: true }); - expect( - await screen.findByDisplayValue('Existing connection'), - ).toBeInTheDocument(); + if (closeAction === 'Late X') { + await waitFor(() => + expect( + fetchMock.callHistory.calls('glob:*/api/v1/database/10/connection'), + ).toHaveLength(1), + ); + } else { + expect( + await screen.findByDisplayValue('Existing connection'), + ).toBeInTheDocument(); + } await userEvent.click(document.querySelector('.ant-modal-wrap')!); expect(screen.getByRole('dialog')).toBeInTheDocument(); if (closeAction === 'Finish') { await userEvent.click(screen.getByRole('button', { name: 'Finish' })); - } else if (closeAction === 'X') { + } else if (closeAction === 'X' || closeAction === 'Late X') { await userEvent.click(screen.getAllByLabelText('Close')[0]); } else { await userEvent.click(screen.getByText('Close')); @@ -412,9 +434,15 @@ describe('DatabaseModal', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), ); - fetchMock.clearHistory(); + if (closeAction !== 'Late X') fetchMock.clearHistory(); await userEvent.click(screen.getByRole('button', { name: '+ Database' })); await screen.findByRole('dialog'); + if (closeAction === 'Late X') { + await act(async () => { + releaseEditFetch(); + await fetchMock.callHistory.flush(true); + }); + } const picker = screen.queryByRole('heading', { name: /select a database to connect/i, }); @@ -480,6 +508,7 @@ describe('DatabaseModal', () => { await screen.findByDisplayValue('Existing connection'), ).toBeInTheDocument(); } finally { + releaseEditFetch(); resourceHook.mockImplementation(previousImplementation); } }; @@ -496,6 +525,57 @@ describe('DatabaseModal', () => { await expectFreshCreateAfterEdit('Finish'); }); + test('late edit fetch after X then creating uses POST without the old id', async () => { + await expectFreshCreateAfterEdit('Late X'); + }); + + test('switching edit targets loads the active target while the previous fetch is pending', async () => { + const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); + const previousImplementation = resourceHook.getMockImplementation()!; + resourceHook.mockImplementation(realUseSingleViewResource); + let releaseEditFetch: () => void = () => {}; + const editFetchReady = new Promise(resolve => { + releaseEditFetch = resolve; + }); + const connection = { + backend: 'sqlite', + configuration_method: ConfigurationMethod.SqlalchemyUri, + sqlalchemy_uri: 'sqlite:///existing.db', + }; + fetchMock.get('glob:*/api/v1/database/10/connection', async () => { + await editFetchReady; + return { + result: { ...connection, id: 10, database_name: 'Previous target' }, + }; + }); + fetchMock.get('glob:*/api/v1/database/20/connection', { + result: { ...connection, id: 20, database_name: 'Active target' }, + }); + try { + const { rerender } = setup({ databaseId: 10 }); + await waitFor(() => + expect( + fetchMock.callHistory.calls('glob:*/api/v1/database/10/connection'), + ).toHaveLength(1), + ); + rerender(); + expect( + await screen.findByDisplayValue('Active target'), + ).toBeInTheDocument(); + await act(async () => { + releaseEditFetch(); + await fetchMock.callHistory.flush(true); + }); + expect(screen.getByDisplayValue('Active target')).toBeInTheDocument(); + expect( + screen.queryByDisplayValue('Previous target'), + ).not.toBeInTheDocument(); + } finally { + releaseEditFetch(); + resourceHook.mockImplementation(previousImplementation); + } + }); + // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('Visual: New database connection', () => { test('renders the initial load of Step 1 correctly', async () => { @@ -1644,71 +1724,128 @@ describe('DatabaseModal', () => { }); }); - test('keeps a created PostgreSQL field connection on step 3 without a saved schema', async () => { - const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); - const previousImplementation = resourceHook.getMockImplementation()!; - resourceHook.mockImplementation(realUseSingleViewResource); - try { - fetchMock.modifyRoute('database-connect', { - response: { - id: 10, + test.each(['Finish', 'Back'] as const)( + 'keeps a created PostgreSQL field connection on step 3 without a saved schema: %s', + async action => { + const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); + const previousImplementation = resourceHook.getMockImplementation()!; + resourceHook.mockImplementation(realUseSingleViewResource); + let releaseBackFetch: () => void = () => {}; + const backFetchReady = new Promise(resolve => { + releaseBackFetch = resolve; + }); + fetchMock.get('glob:*/api/v1/database/10/connection', async () => { + await backFetchReady; + return { result: { id: 10, database_name: 'PostgreSQL', backend: 'postgresql', configuration_method: ConfigurationMethod.DynamicForm, }, - }, + }; }); - fetchMock.put(DATABASE_FETCH_ENDPOINT, { result: { id: 10 } }); - const addDangerToast = jest.fn(); - setup({ addDangerToast }); - await userEvent.click( - await screen.findByRole('button', { name: /postgresql/i }), - ); - expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); - const fieldByName = (name: string) => - document.querySelector(`input[name="${name}"]`)!; - for (const [name, value] of Object.entries({ - host: 'localhost', - port: '5432', - database: 'postgres', - username: 'testdb', - })) { - fireEvent.change(fieldByName(name), { target: { value } }); - } - fireEvent.blur(fieldByName('username')); - const connectButton = screen.getByRole('button', { name: 'Connect' }); - await waitFor(() => expect(connectButton).toBeEnabled()); - fireEvent.click(connectButton); - await waitFor(() => + try { + fetchMock.modifyRoute('database-connect', { + response: { + id: 10, + result: { + id: 10, + database_name: 'PostgreSQL', + backend: 'postgresql', + configuration_method: ConfigurationMethod.DynamicForm, + }, + }, + }); + fetchMock.put(DATABASE_FETCH_ENDPOINT, { result: { id: 10 } }); + const addDangerToast = jest.fn(); + setup({ addDangerToast }); + await userEvent.click( + await screen.findByRole('button', { name: /postgresql/i }), + ); + expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument(); + const fieldByName = (name: string) => + document.querySelector( + `input[name="${name}"]`, + )!; + for (const [name, value] of Object.entries({ + host: 'localhost', + port: '5432', + database: 'postgres', + username: 'testdb', + })) { + fireEvent.change(fieldByName(name), { target: { value } }); + } + fireEvent.blur(fieldByName('username')); + const connectButton = screen.getByRole('button', { + name: 'Connect', + }); + await waitFor(() => expect(connectButton).toBeEnabled()); + fireEvent.click(connectButton); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), + ).toHaveLength(1), + ); + expect(await screen.findByText(/step 3 of 3/i)).toBeInTheDocument(); + expect(screen.getByText('Database connected')).toBeInTheDocument(); expect( - fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), - ).toHaveLength(1), - ); - expect(await screen.findByText(/step 3 of 3/i)).toBeInTheDocument(); - expect(screen.getByText('Database connected')).toBeInTheDocument(); - expect(screen.queryByText('SQLAlchemy URI')).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'Finish' })); - await waitFor(() => + screen.queryByText('SQLAlchemy URI'), + ).not.toBeInTheDocument(); + if (action === 'Back') { + await userEvent.click( + screen.getByRole('button', { name: 'Back' }), + ); + await waitFor(() => + expect( + fetchMock.callHistory.calls( + 'glob:*/api/v1/database/10/connection', + ), + ).toHaveLength(1), + ); + await userEvent.click( + screen.getByRole('button', { name: 'Back' }), + ); + expect( + await screen.findByRole('heading', { + name: /select a database to connect/i, + }), + ).toBeInTheDocument(); + await act(async () => { + releaseBackFetch(); + await fetchMock.callHistory.flush(true); + }); + expect( + screen.getByRole('heading', { + name: /select a database to connect/i, + }), + ).toBeInTheDocument(); + return; + } + await userEvent.click( + screen.getByRole('button', { name: 'Finish' }), + ); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT), + ).toHaveLength(1), + ); expect( - fetchMock.callHistory.calls(DATABASE_FETCH_ENDPOINT), - ).toHaveLength(1), - ); - expect( - JSON.parse( - fetchMock.callHistory.lastCall(DATABASE_FETCH_ENDPOINT)!.options - .body as string, - ), - ).toEqual( - expect.objectContaining({ - configuration_method: ConfigurationMethod.DynamicForm, - }), - ); - } finally { - resourceHook.mockImplementation(previousImplementation); - } - }); + JSON.parse( + fetchMock.callHistory.lastCall(DATABASE_FETCH_ENDPOINT)!.options + .body as string, + ), + ).toEqual( + expect.objectContaining({ + configuration_method: ConfigurationMethod.DynamicForm, + }), + ); + } finally { + releaseBackFetch(); + resourceHook.mockImplementation(previousImplementation); + } + }, + ); test('does not fire redundant validation on blur when db has not changed', async () => { setup(); diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 5b8ef34e2186..39f73baf624a 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -668,7 +668,7 @@ const DatabaseModal: FunctionComponent = ({ >(dbReducer, null); // Database fetch logic const { - state: { loading: dbLoading, resource: dbFetched, error: dbErrors }, + state: { resource: dbFetched, error: dbErrors }, fetchResource, createResource, updateResource, @@ -680,6 +680,8 @@ const DatabaseModal: FunctionComponent = ({ 'connection', ); + const databaseFetchGeneration = useRef(0); + const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY); const [availableDbs, getAvailableDbs] = useAvailableDatabases(); const [ @@ -922,6 +924,7 @@ const DatabaseModal: FunctionComponent = ({ }, [db, getValidation]); const onClose = () => { + databaseFetchGeneration.current += 1; setDB({ type: ActionType.Reset }); setHasConnectedDb(false); handleClearValidationErrors(); // reset validation errors on close @@ -1171,19 +1174,26 @@ const DatabaseModal: FunctionComponent = ({ setLoading(false); }; + /** Fetch only into the modal session that requested the database. */ + const fetchDatabase = (id: number) => { + const generation = databaseFetchGeneration.current; + return fetchResource( + id, + () => show && generation === databaseFetchGeneration.current, + ); + }; + // Initialize const fetchDB = () => { if (isEditMode && databaseId) { - if (!dbLoading) { - fetchResource(databaseId).catch(e => - addDangerToast( - t( - 'Sorry there was an error fetching database information: %s', - e.message, - ), + fetchDatabase(databaseId).catch(e => + addDangerToast( + t( + 'Sorry there was an error fetching database information: %s', + e.message, ), - ); - } + ), + ); } }; @@ -1342,13 +1352,14 @@ const DatabaseModal: FunctionComponent = ({ const handleBackButtonOnFinish = () => { if (dbFetched) { - fetchResource(dbFetched.id as number); + fetchDatabase(dbFetched.id as number); } setShowCTAbtns(false); setEditNewDb(true); }; const handleBackButtonOnConnect = () => { + databaseFetchGeneration.current += 1; handleClearValidationErrors(); if (editNewDb) setHasConnectedDb(false); if (importingModal) setImportingModal(false); @@ -1534,6 +1545,10 @@ const DatabaseModal: FunctionComponent = ({ if (databaseId && show) { fetchDB(); } + return () => { + // Closing or switching targets invalidates outstanding connection reads. + databaseFetchGeneration.current += 1; + }; }, [show, databaseId]); useEffect(() => { @@ -1903,7 +1918,7 @@ const DatabaseModal: FunctionComponent = ({ const fetchAndSetDB = () => { setLoading(true); - fetchResource(dbFetched?.id as number).then(r => { + fetchDatabase(dbFetched?.id as number).then(r => { setItem(LocalStorageKeys.Database, r); }); }; diff --git a/superset-frontend/src/views/CRUD/hooks.test.tsx b/superset-frontend/src/views/CRUD/hooks.test.tsx index 191033706749..d7a529dd62e6 100644 --- a/superset-frontend/src/views/CRUD/hooks.test.tsx +++ b/superset-frontend/src/views/CRUD/hooks.test.tsx @@ -939,6 +939,52 @@ test('useSingleViewResource: fetchResource appends pathSuffix', async () => { }); }); +test('useSingleViewResource: discarded fetch does not replace the active resource', async () => { + const response = deferredJsonResponse(); + jest.spyOn(SupersetClient, 'get').mockReturnValue(response.promise); + const { result } = renderHook(() => + useSingleViewResource('database', 'Database', jest.fn()), + ); + let active = true; + let request: Promise = Promise.resolve(); + act(() => { + result.current.setResource({ id: 20 }); + request = result.current.fetchResource(10, () => active); + }); + active = false; + await act(async () => { + response.resolve({ + json: { result: { id: 10 } }, + } as unknown as JsonResponse); + expect(await request).toEqual({ id: 10 }); + }); + expect(result.current.state.resource).toEqual({ id: 20 }); + expect(result.current.state.loading).toBe(false); +}); + +test('useSingleViewResource: discarded fetch errors do not update state or show a toast', async () => { + const response = deferredJsonResponse(); + jest.spyOn(SupersetClient, 'get').mockReturnValue(response.promise); + const handleError = jest.fn(); + const { result } = renderHook(() => + useSingleViewResource('database', 'Database', handleError), + ); + let active = true; + let request: Promise = Promise.resolve(); + act(() => { + request = result.current.fetchResource(10, () => active); + }); + active = false; + await act(async () => { + response.reject({ error: 'Fetch failed' }); + await request; + }); + expect(result.current.state.resource).toBeNull(); + expect(result.current.state.error).toBeNull(); + expect(result.current.state.loading).toBe(false); + expect(handleError).not.toHaveBeenCalled(); +}); + test('useSingleViewResource: createResource posts to correct endpoint', async () => { const postSpy = jest.spyOn(SupersetClient, 'post').mockResolvedValue({ json: { id: 99, result: { name: 'New Chart' } }, diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index addc9aeb3d81..bf4ce390e074 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -428,7 +428,7 @@ export function useSingleViewResource< ); const fetchResource = useCallback( - (resourceID: number) => { + (resourceID: number, shouldApplyResponse: () => boolean = () => true) => { // Set loading state updateState({ loading: true, @@ -442,13 +442,16 @@ export function useSingleViewResource< }) .then( ({ json = {} }) => { - updateState({ - resource: json.result, - error: null, - }); + if (shouldApplyResponse()) { + updateState({ + resource: json.result, + error: null, + }); + } return json.result; }, createErrorHandler((errMsg: Record) => { + if (!shouldApplyResponse()) return; handleErrorMsg( t( 'An error occurred while fetching %ss: %s', From c3fe8f85cd9d8f822458eb755260e77d7a6dc4e4 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Sat, 31 Oct 2026 12:32:06 +0000 Subject: [PATCH 12/12] fix(databases): ignore late modal connection writes --- .../creating-your-first-dashboard.mdx | 5 + .../databases/DatabaseModal/index.test.tsx | 145 ++++++++++++++++++ .../databases/DatabaseModal/index.tsx | 43 ++++-- .../src/views/CRUD/hooks.test.tsx | 83 ++++++++++ superset-frontend/src/views/CRUD/hooks.ts | 36 +++-- 5 files changed, 289 insertions(+), 23 deletions(-) diff --git a/docs/docs/using-superset/creating-your-first-dashboard.mdx b/docs/docs/using-superset/creating-your-first-dashboard.mdx index 5aecfc457c4a..79e216fb74ca 100644 --- a/docs/docs/using-superset/creating-your-first-dashboard.mdx +++ b/docs/docs/using-superset/creating-your-first-dashboard.mdx @@ -83,6 +83,11 @@ for your target database. Click the **Test Connection** button to confirm things work end to end. If the connection looks good, save the configuration by clicking the **Connect** button in the bottom right corner of the modal window: +Closing the connection window starts a fresh session the next time you add a database. +A pending save may still complete on the server, but its response does not populate +or close the new connection window. Check the database list before retrying a save +that was pending when you closed the window. + Congratulations, you've just added a new data source in Superset! ### Sharing a Database Connection diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx index 9f6f88f2748c..5e79ffea750c 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx @@ -529,6 +529,149 @@ describe('DatabaseModal', () => { await expectFreshCreateAfterEdit('Late X'); }); + /** Check that late writes cannot populate or close a subsequent create session. */ + const expectFreshCreateAfterLateWrite = async ( + operation: 'create' | 'update', + ): Promise => { + const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); + const previousImplementation = resourceHook.getMockImplementation()!; + resourceHook.mockImplementation(realUseSingleViewResource); + let releaseWrite: () => void = () => {}; + const writeReady = new Promise(resolve => { + releaseWrite = resolve; + }); + const connection = { + id: 77, + database_name: 'First connection', + backend: 'postgresql', + configuration_method: ConfigurationMethod.DynamicForm, + }; + let createCount = 0; + fetchMock.modifyRoute('database-connect', { + response: async () => { + createCount += 1; + if (operation === 'create' && createCount === 1) await writeReady; + const id = createCount === 1 ? 77 : 88; + return { id, result: { ...connection, id } }; + }, + }); + fetchMock.put('glob:*/api/v1/database/77', async () => { + await writeReady; + return { id: 77, result: connection }; + }); + const onDatabaseAdd = jest.fn(); + const Harness = () => { + const [show, setShow] = useState(true); + return ( + <> + + setShow(false)} + onDatabaseAdd={onDatabaseAdd} + /> + + ); + }; + /** Fill a new dynamic-form connection without relying on saved state. */ + const connectPostgres = async (name: string, host: string) => { + await userEvent.click( + await screen.findByRole('button', { name: /postgresql/i }), + ); + fireEvent.change( + document.querySelector( + 'input[name="database_name"]', + )!, + { + target: { value: name }, + }, + ); + for (const [field, value] of Object.entries({ + host, + port: '5432', + database: 'postgres', + username: 'testdb', + })) { + fireEvent.change( + document.querySelector(`input[name="${field}"]`)!, + { target: { value } }, + ); + } + fireEvent.blur( + document.querySelector('input[name="username"]')!, + ); + const connect = screen.getByRole('button', { name: 'Connect' }); + await waitFor(() => expect(connect).toBeEnabled()); + await userEvent.click(connect); + }; + try { + render(, { useRedux: true }); + await connectPostgres('First connection', 'first.example'); + await waitFor(() => + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), + ).toHaveLength(1), + ); + if (operation === 'update') { + expect(await screen.findByText(/step 3 of 3/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Finish' })); + await waitFor(() => + expect( + fetchMock.callHistory.calls('glob:*/api/v1/database/77'), + ).toHaveLength(1), + ); + } + await userEvent.click(screen.getAllByLabelText('Close')[0]); + await waitFor(() => + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), + ); + await userEvent.click(screen.getByRole('button', { name: '+ Database' })); + await screen.findByRole('dialog'); + onDatabaseAdd.mockClear(); + await act(async () => { + releaseWrite(); + await fetchMock.callHistory.flush(true); + }); + expect( + screen.getByRole('heading', { + name: /select a database to connect/i, + }), + ).toBeInTheDocument(); + expect(onDatabaseAdd).not.toHaveBeenCalled(); + fetchMock.clearHistory(); + await connectPostgres('Second connection', 'second.example'); + expect(await screen.findByText(/step 3 of 3/i)).toBeInTheDocument(); + expect( + fetchMock.callHistory.calls(DATABASE_CONNECT_ENDPOINT), + ).toHaveLength(1); + expect( + fetchMock.callHistory.calls('glob:*/api/v1/database/77', { + method: 'PUT', + }), + ).toHaveLength(0); + const payload = JSON.parse( + fetchMock.callHistory.lastCall(DATABASE_CONNECT_ENDPOINT)!.options + .body as string, + ); + expect(payload).not.toHaveProperty('id'); + expect(payload.database_name).toBe('Second connection'); + expect(payload.parameters.host).toBe('second.example'); + } finally { + releaseWrite(); + resourceHook.mockImplementation(previousImplementation); + } + }; + + test('late create response after X leaves the next connection as a fresh POST', async () => { + await expectFreshCreateAfterLateWrite('create'); + }); + + test('late update response after X leaves the next connection as a fresh POST', async () => { + await expectFreshCreateAfterLateWrite('update'); + }); + test('switching edit targets loads the active target while the previous fetch is pending', async () => { const resourceHook = jest.spyOn(hooks, 'useSingleViewResource'); const previousImplementation = resourceHook.getMockImplementation()!; @@ -3250,6 +3393,8 @@ test.each([true, false])( sqlalchemy_uri: 'databricks://example.cloud.databricks.com', }), false, + true, + expect.any(Function), ); } } finally { diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 39f73baf624a..1fe4ab87a604 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -680,7 +680,18 @@ const DatabaseModal: FunctionComponent = ({ 'connection', ); - const databaseFetchGeneration = useRef(0); + const databaseRequestGeneration = useRef(0); + const dbFetchedGeneration = useRef(0); + + /** Bind resource publication and save continuations to the requesting session. */ + const getResponseGuard = () => { + const generation = databaseRequestGeneration.current; + return () => { + const active = show && generation === databaseRequestGeneration.current; + if (active) dbFetchedGeneration.current = generation; + return active; + }; + }; const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY); const [availableDbs, getAvailableDbs] = useAvailableDatabases(); @@ -924,7 +935,7 @@ const DatabaseModal: FunctionComponent = ({ }, [db, getValidation]); const onClose = () => { - databaseFetchGeneration.current += 1; + databaseRequestGeneration.current += 1; setDB({ type: ActionType.Reset }); setHasConnectedDb(false); handleClearValidationErrors(); // reset validation errors on close @@ -970,6 +981,7 @@ const DatabaseModal: FunctionComponent = ({ }); const onSave = async () => { + const shouldApplyResponse = getResponseGuard(); let dbConfigExtraExtensionOnSaveError; setLoading(true); setHasValidated(false); @@ -1002,6 +1014,7 @@ const DatabaseModal: FunctionComponent = ({ } const errors = await getValidation(dbToUpdate, true); + if (!shouldApplyResponse()) return; // ``getValidation`` returns ``[]`` on success, a field-keyed object // for blocking errors (e.g. the duplicate ``database_name`` check), // and ``null`` for stale or unexpected responses. During save we @@ -1096,7 +1109,10 @@ const DatabaseModal: FunctionComponent = ({ db.id as number, dbToUpdate as DatabaseObject, dbToUpdate.configuration_method === ConfigurationMethod.DynamicForm, // onShow toast on SQLA Forms + true, + shouldApplyResponse, ); + if (!shouldApplyResponse()) return; if (result) { if (onDatabaseAdd) onDatabaseAdd(); dbConfigExtraExtension @@ -1121,7 +1137,9 @@ const DatabaseModal: FunctionComponent = ({ const dbId = await createResource( dbToUpdate as DatabaseObject, dbToUpdate.configuration_method === ConfigurationMethod.DynamicForm, // onShow toast on SQLA Forms + shouldApplyResponse, ); + if (!shouldApplyResponse()) return; if (dbId) { setHasConnectedDb(true); if (onDatabaseAdd) onDatabaseAdd(); @@ -1175,13 +1193,7 @@ const DatabaseModal: FunctionComponent = ({ }; /** Fetch only into the modal session that requested the database. */ - const fetchDatabase = (id: number) => { - const generation = databaseFetchGeneration.current; - return fetchResource( - id, - () => show && generation === databaseFetchGeneration.current, - ); - }; + const fetchDatabase = (id: number) => fetchResource(id, getResponseGuard()); // Initialize const fetchDB = () => { @@ -1359,7 +1371,7 @@ const DatabaseModal: FunctionComponent = ({ }; const handleBackButtonOnConnect = () => { - databaseFetchGeneration.current += 1; + databaseRequestGeneration.current += 1; handleClearValidationErrors(); if (editNewDb) setHasConnectedDb(false); if (importingModal) setImportingModal(false); @@ -1546,13 +1558,18 @@ const DatabaseModal: FunctionComponent = ({ fetchDB(); } return () => { - // Closing or switching targets invalidates outstanding connection reads. - databaseFetchGeneration.current += 1; + // Closing or switching targets invalidates outstanding connection requests. + databaseRequestGeneration.current += 1; }; }, [show, databaseId]); useEffect(() => { - if (show && dbFetched && (!databaseId || dbFetched.id === databaseId)) { + if ( + show && + dbFetched && + dbFetchedGeneration.current === databaseRequestGeneration.current && + (!databaseId || dbFetched.id === databaseId) + ) { setDB({ type: ActionType.Fetched, isEditMode, diff --git a/superset-frontend/src/views/CRUD/hooks.test.tsx b/superset-frontend/src/views/CRUD/hooks.test.tsx index d7a529dd62e6..101594cfcc50 100644 --- a/superset-frontend/src/views/CRUD/hooks.test.tsx +++ b/superset-frontend/src/views/CRUD/hooks.test.tsx @@ -1038,6 +1038,89 @@ test('useSingleViewResource: updateResource puts to correct endpoint', async () }); }); +test.each(['create', 'update'] as const)( + 'useSingleViewResource: discarded %s preserves the active resource and returns its response', + async operation => { + const response = deferredJsonResponse(); + jest + .spyOn(SupersetClient, operation === 'create' ? 'post' : 'put') + .mockReturnValue(response.promise); + const { result } = renderHook(() => + useSingleViewResource<{ id?: number; name?: string }>( + 'database', + 'Database', + jest.fn(), + ), + ); + let active = true; + let request: Promise = Promise.resolve(); + act(() => { + request = + operation === 'create' + ? result.current.createResource({ name: 'Old' }, false, () => active) + : result.current.updateResource( + 77, + { name: 'Old' }, + false, + true, + () => active, + ); + }); + active = false; + act(() => result.current.setResource({ id: 88, name: 'Active' })); + await act(async () => { + response.resolve({ + json: { id: 77, result: { name: 'Old' } }, + } as unknown as JsonResponse); + expect(await request).toEqual( + operation === 'create' ? 77 : { name: 'Old' }, + ); + }); + expect(result.current.state.resource).toEqual({ id: 88, name: 'Active' }); + expect(result.current.state.error).toBeNull(); + }, +); + +test.each(['create', 'update'] as const)( + 'useSingleViewResource: discarded %s errors do not update state or show a toast', + async operation => { + const response = deferredJsonResponse(); + jest + .spyOn(SupersetClient, operation === 'create' ? 'post' : 'put') + .mockReturnValue(response.promise); + const handleError = jest.fn(); + const { result } = renderHook(() => + useSingleViewResource<{ name: string }>( + 'database', + 'Database', + handleError, + ), + ); + let active = true; + let request: Promise = Promise.resolve(); + act(() => { + request = + operation === 'create' + ? result.current.createResource({ name: 'Old' }, false, () => active) + : result.current.updateResource( + 77, + { name: 'Old' }, + false, + true, + () => active, + ); + }); + active = false; + await act(async () => { + response.reject({ error: 'Write failed' }); + await request; + }); + expect(result.current.state.resource).toBeNull(); + expect(result.current.state.error).toBeNull(); + expect(handleError).not.toHaveBeenCalled(); + }, +); + test('useSingleViewResource: clearError resets error to null', async () => { // First make a failing request to get an error state jest.spyOn(SupersetClient, 'get').mockRejectedValue('Network error'); diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index bf4ce390e074..49a16d276181 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -473,7 +473,11 @@ export function useSingleViewResource< ); const createResource = useCallback( - (resource: P, hideToast = false) => { + ( + resource: P, + hideToast = false, + shouldApplyResponse: () => boolean = () => true, + ) => { // Set loading state updateState({ loading: true, @@ -486,13 +490,16 @@ export function useSingleViewResource< }) .then( ({ json = {} }) => { - updateState({ - resource: { id: json.id, ...json.result }, - error: null, - }); + if (shouldApplyResponse()) { + updateState({ + resource: { id: json.id, ...json.result }, + error: null, + }); + } return json.id; }, createErrorHandler((errMsg: Record) => { + if (!shouldApplyResponse()) return; // we did not want toasts for db-connection-ui but did not want to disable it everywhere if (!hideToast) { handleErrorMsg( @@ -517,7 +524,13 @@ export function useSingleViewResource< ); const updateResource = useCallback( - (resourceID: number, resource: P, hideToast = false, setLoading = true) => { + ( + resourceID: number, + resource: P, + hideToast = false, + setLoading = true, + shouldApplyResponse: () => boolean = () => true, + ) => { // Set loading state if (setLoading) { updateState({ @@ -532,13 +545,16 @@ export function useSingleViewResource< }) .then( ({ json = {} }) => { - updateState({ - resource: { ...json.result, id: json.id }, - error: null, - }); + if (shouldApplyResponse()) { + updateState({ + resource: { ...json.result, id: json.id }, + error: null, + }); + } return json.result; }, createErrorHandler(errMsg => { + if (!shouldApplyResponse()) return errMsg; if (!hideToast) { handleErrorMsg( t(