You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2026/04/23 17:37:17 UTC

(superset) branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321 created (now 808d905c9ee)

This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a change to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git


      at 808d905c9ee Fix edit dataset

This branch includes the following new commits:

     new ebbf4777e37 feat: API for semantic layers
     new bc58d13c347 Address comments
     new 469acf12b28 Update permissions
     new 379403834ad feat: UI for semantic layers
     new 4c645ce952d Fix rebase
     new 8c69f13a717 feat: CRUD for adding/deleting semantic views
     new a1a05134d23 Fix imports
     new 4490bffd500 Fix lint/tests
     new fc5a3cd32be Improve design
     new 1f43b3fa0ee Address comments
     new cc15ffd0449 Bulk delete
     new 3c20d62ed24 Small fixes
     new 1b089f828ae chore: rename database/database when using semantic layers
     new 47973bd7892 More fixes
     new bf71d49bd69 Revert some aggressive renames
     new 1d4f31c4dfb Fix lint
     new 4827fbb6156 Small fixes
     new 580243d84a4 Fix data connection name
     new 07712fbd20d Fix header name
     new 5b4035427dc Working on filters
     new ab52bd14c4c Working on filters
     new 0e249e6348b Fix filters
     new b2784f297ca Fix lint
     new 152f11d1d41 Small fixes
     new 808d905c9ee Fix edit dataset

The 25 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 17/25: Small fixes

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 4827fbb615655515a1b8f728f944e1f5ca6e2508
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Apr 16 18:55:48 2026 -0400

    Small fixes
---
 docker/pythonpath_dev/superset_config.py     |  8 +++++++-
 superset-frontend/package-lock.json          | 24 ++++++++++++------------
 superset/commands/datasource/list.py         | 15 ++++++++++++++-
 superset/daos/semantic_layer.py              |  8 +++++---
 superset/semantic_layers/api.py              |  9 +++++++--
 superset/semantic_layers/mapper.py           | 14 +++++++-------
 tests/unit_tests/semantic_layers/api_test.py |  9 +++------
 7 files changed, 55 insertions(+), 32 deletions(-)

diff --git a/docker/pythonpath_dev/superset_config.py b/docker/pythonpath_dev/superset_config.py
index 108305cf900..ce7f1998708 100644
--- a/docker/pythonpath_dev/superset_config.py
+++ b/docker/pythonpath_dev/superset_config.py
@@ -105,7 +105,13 @@ class CeleryConfig:
 
 CELERY_CONFIG = CeleryConfig
 
-FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True}
+FEATURE_FLAGS = {
+    "ALERT_REPORTS": True,
+    "DATASET_FOLDERS": True,
+    "ENABLE_EXTENSIONS": True,
+    "SEMANTIC_LAYERS": True,
+}
+EXTENSIONS_PATH = "/app/docker/extensions"
 ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
 WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/"  # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/  # noqa: E501
 # The base URL for the email report hyperlinks.
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index f7ea57d8f44..11fe2949d37 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -4076,6 +4076,18 @@
         "url": "https://github.com/sponsors/ayuhito"
       }
     },
+    "node_modules/@googleapis/sheets": {
+      "version": "13.0.1",
+      "resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
+      "integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "googleapis-common": "^8.0.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/@great-expectations/jsonforms-antd-renderers": {
       "version": "2.2.11",
       "resolved": "https://registry.npmjs.org/@great-expectations/jsonforms-antd-renderers/-/jsonforms-antd-renderers-2.2.11.tgz",
@@ -4096,18 +4108,6 @@
         "react": "^17 || ^18"
       }
     },
-    "node_modules/@googleapis/sheets": {
-      "version": "13.0.1",
-      "resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
-      "integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "googleapis-common": "^8.0.0"
-      },
-      "engines": {
-        "node": ">=12.0.0"
-      }
-    },
     "node_modules/@hapi/address": {
       "version": "5.1.1",
       "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
diff --git a/superset/commands/datasource/list.py b/superset/commands/datasource/list.py
index 75ead5516d8..7966f0189bf 100644
--- a/superset/commands/datasource/list.py
+++ b/superset/commands/datasource/list.py
@@ -65,6 +65,9 @@ class GetCombinedDatasourceListCommand(BaseCommand):
         source_type, name_filter, sql_filter, type_filter = self._parse_filters(filters)
         source_type = self._resolve_source_type(source_type, sql_filter, type_filter)
 
+        if source_type == "empty":
+            return {"count": 0, "result": []}
+
         ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter)
         sv_q = DatasourceDAO.build_semantic_view_query(name_filter)
 
@@ -108,8 +111,18 @@ class GetCombinedDatasourceListCommand(BaseCommand):
         sql_filter: bool | None,
         type_filter: str | None,
     ) -> str:
-        """Narrow source_type based on access flags, sql filter, and type filter."""
+        """Narrow source_type based on access flags, sql filter, and type filter.
+
+        Returns one of: "database", "semantic_layer", "all", or "empty".
+        "empty" signals that the caller should short-circuit and return no results
+        (used when the user explicitly requests semantic views but lacks access).
+        """
         if not self._can_read_semantic_views:
+            # If the user explicitly asked for semantic views but cannot read them,
+            # return "empty" so the caller yields zero results rather than silently
+            # falling back to the full dataset list.
+            if source_type == "semantic_layer" or type_filter == "semantic_view":
+                return "empty"
             return "database"
         if not self._can_read_datasets:
             return "semantic_layer"
diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py
index 9690a2fa12b..c92745bd119 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -22,18 +22,20 @@ from __future__ import annotations
 from typing import Any
 
 from sqlalchemy.exc import StatementError
-
 from superset_core.semantic_layers.daos import (
     AbstractSemanticLayerDAO,
     AbstractSemanticViewDAO,
 )
 
+from superset.daos.base import BaseDAO
 from superset.extensions import db
 from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.utils import json
 
 
-class SemanticLayerDAO(AbstractSemanticLayerDAO):
+class SemanticLayerDAO(BaseDAO[SemanticLayer], AbstractSemanticLayerDAO):
+    # SemanticLayer uses uuid as the primary key
+    id_column_name = "uuid"
     """
     Data Access Object for SemanticLayer model.
     """
@@ -112,7 +114,7 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO):
         )
 
 
-class SemanticViewDAO(AbstractSemanticViewDAO):
+class SemanticViewDAO(BaseDAO[SemanticView], AbstractSemanticViewDAO):
     """Data Access Object for SemanticView model."""
 
     model_cls = SemanticView
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index b5d5bdc6bf2..f161ce53465 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -55,7 +55,7 @@ from superset.commands.semantic_layer.update import (
     UpdateSemanticViewCommand,
 )
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
-from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
+from superset.daos.semantic_layer import SemanticLayerDAO
 from superset.datasets.schemas import get_delete_ids_schema
 from superset.models.core import Database
 from superset.semantic_layers.models import SemanticLayer, SemanticView
@@ -174,6 +174,11 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
     class_permission_name = "SemanticView"
     method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
     include_route_methods = {"put", "post", "delete", "bulk_delete"}
+    # SemanticViewRestApi exposes only write endpoints, but can_read must be
+    # declared explicitly so that FAB registers the permission. It is used by
+    # DatasourceRestApi.combined_list to gate access to semantic views in the
+    # combined datasource list.
+    base_permissions = ["can_read", "can_write"]
 
     edit_model_schema = SemanticViewPutSchema()
 
@@ -625,7 +630,7 @@ class SemanticLayerRestApi(BaseSupersetApi):
             )
 
         # Check which views already exist with the same runtime config
-        existing = SemanticViewDAO.find_by_semantic_layer(str(layer.uuid))
+        existing = SemanticLayerDAO.get_semantic_views(str(layer.uuid))
         existing_keys: set[tuple[str, str]] = set()
         for v in existing:
             config = v.configuration
diff --git a/superset/semantic_layers/mapper.py b/superset/semantic_layers/mapper.py
index f2c13865cdf..23dea06124d 100644
--- a/superset/semantic_layers/mapper.py
+++ b/superset/semantic_layers/mapper.py
@@ -291,11 +291,7 @@ def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
 
     metrics = [all_metrics[metric] for metric in (query_object.metrics or [])]
 
-    grain = (
-        _convert_time_grain(query_object.extras["time_grain_sqla"])
-        if "time_grain_sqla" in query_object.extras
-        else None
-    )
+    grain = _convert_time_grain(query_object.extras.get("time_grain_sqla"))
     dimensions = [
         dimension
         for dimension in semantic_view.dimensions
@@ -740,13 +736,17 @@ def _get_group_limit_filters(
     return filters if filters else None
 
 
-def _convert_time_grain(time_grain: str) -> Grain | None:
+def _convert_time_grain(time_grain: str | None) -> Grain | None:
     """
     Convert a time grain string (ISO 8601 duration) to a Grain instance.
+
+    Returns None when ``time_grain`` is None or empty (no grain selected).
     """
+    if not time_grain:
+        return None
     try:
         return Grains.get(time_grain)
-    except (ValueError, isodate.ISO8601Error):
+    except (TypeError, ValueError, isodate.ISO8601Error):
         return None
 
 
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index f7acfb47e34..47e8aa24135 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -1813,8 +1813,7 @@ def test_get_views(
     mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
     mock_dao.find_by_uuid.return_value = mock_layer
 
-    mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
-    mock_sv_dao.find_by_semantic_layer.return_value = []
+    mock_dao.get_semantic_views.return_value = []
 
     response = client.post(
         f"/api/v1/semantic_layer/{test_uuid}/views",
@@ -1851,8 +1850,7 @@ def test_get_views_with_existing(
     existing_view.name = "Existing View"
     existing_view.configuration = '{"database": "mydb"}'
 
-    mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
-    mock_sv_dao.find_by_semantic_layer.return_value = [existing_view]
+    mock_dao.get_semantic_views.return_value = [existing_view]
 
     response = client.post(
         f"/api/v1/semantic_layer/{test_uuid}/views",
@@ -1932,8 +1930,7 @@ def test_get_views_existing_dict_config(
     existing_view.name = "View X"
     existing_view.configuration = {"key": "val"}  # dict, not string
 
-    mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
-    mock_sv_dao.find_by_semantic_layer.return_value = [existing_view]
+    mock_dao.get_semantic_views.return_value = [existing_view]
 
     response = client.post(
         f"/api/v1/semantic_layer/{test_uuid}/views",


(superset) 25/25: Fix edit dataset

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 808d905c9ee58a238a65a1968d3316eb468ce384
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 23:25:25 2026 -0400

    Fix edit dataset
---
 .../controls/DatasourceControl/index.tsx           | 35 +++++++++++++++++-----
 .../semanticViews/SemanticViewEditModal.tsx        |  8 ++---
 2 files changed, 31 insertions(+), 12 deletions(-)

diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
index 512795b2d3e..fee5548936d 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
@@ -40,6 +40,7 @@ import {
   DatasourceModal,
   ErrorAlert,
 } from 'src/components';
+import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
 import { Menu } from '@superset-ui/core/components/Menu';
 import { Icons } from '@superset-ui/core/components/Icons';
 import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
@@ -69,6 +70,7 @@ interface ExtendedDatasource extends Datasource {
   }>;
   extra?: string;
   health_check_message?: string;
+  cache_timeout?: number | null;
   database?: {
     id: number;
     database_name: string;
@@ -591,14 +593,31 @@ class DatasourceControl extends PureComponent<
             )}
           </div>
         )}
-        {showEditDatasourceModal && (
-          <DatasourceModal
-            datasource={datasource}
-            show={showEditDatasourceModal}
-            onDatasourceSave={this.onDatasourceSave}
-            onHide={this.toggleEditDatasourceModal}
-          />
-        )}
+        {showEditDatasourceModal &&
+          (datasource.type === DatasourceType.SemanticView ? (
+            <SemanticViewEditModal
+              show={showEditDatasourceModal}
+              onHide={this.toggleEditDatasourceModal}
+              onSave={() => {
+                if (this.props.onDatasourceSave) {
+                  this.props.onDatasourceSave(datasource);
+                }
+              }}
+              semanticView={{
+                id: datasource.id,
+                table_name: datasource.name,
+                description: datasource.description,
+                cache_timeout: datasource.cache_timeout,
+              }}
+            />
+          ) : (
+            <DatasourceModal
+              datasource={datasource}
+              show={showEditDatasourceModal}
+              onDatasourceSave={this.onDatasourceSave}
+              onHide={this.toggleEditDatasourceModal}
+            />
+          ))}
         {showChangeDatasourceModal && (
           <ChangeDatasourceModal
             onDatasourceSave={this.onDatasourceSave}
diff --git a/superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx b/superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx
index 1c51f77b719..0d0f809743b 100644
--- a/superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx
+++ b/superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx
@@ -33,8 +33,8 @@ interface SemanticViewEditModalProps {
   show: boolean;
   onHide: () => void;
   onSave: () => void;
-  addDangerToast: (msg: string) => void;
-  addSuccessToast: (msg: string) => void;
+  addDangerToast?: (msg: string) => void;
+  addSuccessToast?: (msg: string) => void;
   semanticView: {
     id: number;
     table_name: string;
@@ -47,8 +47,8 @@ export default function SemanticViewEditModal({
   show,
   onHide,
   onSave,
-  addDangerToast,
-  addSuccessToast,
+  addDangerToast = () => {},
+  addSuccessToast = () => {},
   semanticView,
 }: SemanticViewEditModalProps) {
   const [description, setDescription] = useState<string>('');


(superset) 04/25: feat: UI for semantic layers

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 379403834adbf7836b0a2882b48852ba8c643ca9
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Feb 11 16:02:37 2026 -0500

    feat: UI for semantic layers
---
 .../src/superset_core/semantic_layers/config.py    |  73 +++
 .../src/superset_core/semantic_layers/layer.py     |   2 +
 superset-frontend/package-lock.json                | 215 ++++++-
 superset-frontend/package.json                     |   7 +
 superset-frontend/src/features/home/SubMenu.tsx    |  29 +-
 .../features/semanticLayers/SemanticLayerModal.tsx | 628 +++++++++++++++++++++
 superset-frontend/src/pages/DatabaseList/index.tsx | 466 ++++++++++++---
 superset/commands/semantic_layer/create.py         |   5 +
 superset/commands/semantic_layer/update.py         |   4 +
 superset/semantic_layers/api.py                    | 275 ++++++++-
 .../commands/semantic_layer/create_test.py         |   3 +-
 .../commands/semantic_layer/update_test.py         | 110 ++++
 tests/unit_tests/semantic_layers/api_test.py       | 588 ++++++++++++++++++-
 13 files changed, 2279 insertions(+), 126 deletions(-)

diff --git a/superset-core/src/superset_core/semantic_layers/config.py b/superset-core/src/superset_core/semantic_layers/config.py
new file mode 100644
index 00000000000..7c9aa520b11
--- /dev/null
+++ b/superset-core/src/superset_core/semantic_layers/config.py
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel
+
+
+def build_configuration_schema(
+    config_class: type[BaseModel],
+    configuration: BaseModel | None = None,
+) -> dict[str, Any]:
+    """
+    Build a JSON schema from a Pydantic configuration class.
+
+    Handles generic boilerplate that any semantic layer with dynamic fields needs:
+
+    - Reorders properties to match model field order (Pydantic sorts alphabetically)
+    - When ``configuration`` is None, sets ``enum: []`` on all ``x-dynamic`` properties
+      so the frontend renders them as empty dropdowns
+
+    Semantic layer implementations call this instead of
+    ``model_json_schema()`` directly,
+    then only need to add their own dynamic population logic.
+    """
+    schema = config_class.model_json_schema()
+
+    # Pydantic sorts properties alphabetically; restore model field order
+    field_order = [
+        field.alias or name for name, field in config_class.model_fields.items()
+    ]
+    schema["properties"] = {
+        key: schema["properties"][key]
+        for key in field_order
+        if key in schema["properties"]
+    }
+
+    if configuration is None:
+        for prop_schema in schema["properties"].values():
+            if prop_schema.get("x-dynamic"):
+                prop_schema["enum"] = []
+
+    return schema
+
+
+def check_dependencies(
+    prop_schema: dict[str, Any],
+    configuration: BaseModel,
+) -> bool:
+    """
+    Check whether a dynamic property's dependencies are satisfied.
+
+    Reads the ``x-dependsOn`` list from the property schema and returns ``True``
+    when every referenced attribute on ``configuration`` is truthy.
+    """
+    dependencies = prop_schema.get("x-dependsOn", [])
+    return all(getattr(configuration, dep, None) for dep in dependencies)
diff --git a/superset-core/src/superset_core/semantic_layers/layer.py b/superset-core/src/superset_core/semantic_layers/layer.py
index c090e79ddbe..2dbffdec003 100644
--- a/superset-core/src/superset_core/semantic_layers/layer.py
+++ b/superset-core/src/superset_core/semantic_layers/layer.py
@@ -32,6 +32,8 @@ class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
     Abstract base class for semantic layers.
     """
 
+    configuration_class: type[BaseModel]
+
     @classmethod
     @abstractmethod
     def from_configuration(
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index eaf5acb1325..f7ea57d8f44 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -28,8 +28,14 @@
         "@emotion/cache": "^11.4.0",
         "@emotion/react": "^11.14.0",
         "@emotion/styled": "^11.14.1",
+        "@fontsource/fira-code": "^5.2.7",
         "@fontsource/ibm-plex-mono": "^5.2.7",
+        "@fontsource/inter": "^5.2.8",
         "@googleapis/sheets": "^13.0.1",
+        "@great-expectations/jsonforms-antd-renderers": "^2.2.10",
+        "@jsonforms/core": "^3.7.0",
+        "@jsonforms/react": "^3.7.0",
+        "@jsonforms/vanilla-renderers": "^3.7.0",
         "@luma.gl/constants": "~9.2.5",
         "@luma.gl/core": "~9.2.5",
         "@luma.gl/engine": "~9.2.5",
@@ -37,6 +43,7 @@
         "@luma.gl/shadertools": "~9.2.5",
         "@luma.gl/webgl": "~9.2.5",
         "@reduxjs/toolkit": "^1.9.3",
+        "@rjsf/antd": "^5.24.13",
         "@rjsf/core": "^5.24.13",
         "@rjsf/utils": "^5.24.3",
         "@rjsf/validator-ajv8": "^5.24.13",
@@ -4042,6 +4049,15 @@
         }
       }
     },
+    "node_modules/@fontsource/fira-code": {
+      "version": "5.2.7",
+      "resolved": "https://registry.npmjs.org/@fontsource/fira-code/-/fira-code-5.2.7.tgz",
+      "integrity": "sha512-tnB9NNund9TwIym8/7DMJe573nlPEQb+fKUV5GL8TBYXjIhDvL0D7mgmNVNQUPhXp+R7RylQeiBdkA4EbOHPGQ==",
+      "license": "OFL-1.1",
+      "funding": {
+        "url": "https://github.com/sponsors/ayuhito"
+      }
+    },
     "node_modules/@fontsource/ibm-plex-mono": {
       "version": "5.2.7",
       "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz",
@@ -4052,15 +4068,34 @@
       }
     },
     "node_modules/@fontsource/inter": {
-      "version": "5.2.6",
-      "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.6.tgz",
-      "integrity": "sha512-CZs9S1CrjD0jPwsNy9W6j0BhsmRSQrgwlTNkgQXTsAeDRM42LBRLo3eo9gCzfH4GvV7zpyf78Ozfl773826csw==",
+      "version": "5.2.8",
+      "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
+      "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
       "license": "OFL-1.1",
-      "peer": true,
       "funding": {
         "url": "https://github.com/sponsors/ayuhito"
       }
     },
+    "node_modules/@great-expectations/jsonforms-antd-renderers": {
+      "version": "2.2.11",
+      "resolved": "https://registry.npmjs.org/@great-expectations/jsonforms-antd-renderers/-/jsonforms-antd-renderers-2.2.11.tgz",
+      "integrity": "sha512-QeKI6RP+vZo5Bf5WX5Mx6CPEBYvR83bIyeezHoyVVc1+pGsDqO9lsFdbaFKpqozV+s/TRB1KmVAW4GxpMzLuAw==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash.isempty": "^4.4.0",
+        "lodash.merge": "^4.6.2",
+        "lodash.range": "^3.2.0",
+        "lodash.startcase": "^4.4.0"
+      },
+      "peerDependencies": {
+        "@ant-design/icons": "^5.3.0",
+        "@jsonforms/core": "^3.3.0",
+        "@jsonforms/react": "^3.3.0",
+        "antd": "^5.14.0",
+        "dayjs": "^1",
+        "react": "^17 || ^18"
+      }
+    },
     "node_modules/@googleapis/sheets": {
       "version": "13.0.1",
       "resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
@@ -6120,6 +6155,45 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@jsonforms/core": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz",
+      "integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/json-schema": "^7.0.3",
+        "ajv": "^8.6.1",
+        "ajv-formats": "^2.1.0",
+        "lodash": "^4.17.21"
+      }
+    },
+    "node_modules/@jsonforms/react": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz",
+      "integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.21"
+      },
+      "peerDependencies": {
+        "@jsonforms/core": "3.7.0",
+        "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
+    "node_modules/@jsonforms/vanilla-renderers": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz",
+      "integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==",
+      "license": "MIT",
+      "dependencies": {
+        "lodash": "^4.17.21"
+      },
+      "peerDependencies": {
+        "@jsonforms/core": "3.7.0",
+        "@jsonforms/react": "3.7.0",
+        "react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/@jsonjoy.com/base64": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz",
@@ -9487,6 +9561,89 @@
       "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
       "license": "MIT"
     },
+    "node_modules/@rjsf/antd": {
+      "version": "5.24.13",
+      "resolved": "https://registry.npmjs.org/@rjsf/antd/-/antd-5.24.13.tgz",
+      "integrity": "sha512-UiWE8xoBxxCoe/SEkdQEmL5E6z3I1pw0+y0dTyGt8SHfAxxFc4/OWn7tKOAiNsKCXgf83t0JKn6CHWLD01sAdQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "classnames": "^2.5.1",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "rc-picker": "2.7.6"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "peerDependencies": {
+        "@ant-design/icons": "^4.0.0 || ^5.0.0",
+        "@rjsf/core": "^5.24.x",
+        "@rjsf/utils": "^5.24.x",
+        "antd": "^4.24.0 || ^5.8.5",
+        "dayjs": "^1.8.0",
+        "react": "^16.14.0 || >=17"
+      }
+    },
+    "node_modules/@rjsf/antd/node_modules/rc-picker": {
+      "version": "2.7.6",
+      "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-2.7.6.tgz",
+      "integrity": "sha512-H9if/BUJUZBOhPfWcPeT15JUI3/ntrG9muzERrXDkSoWmDj4yzmBvumozpxYrHwjcKnjyDGAke68d+whWwvhHA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "^2.2.1",
+        "date-fns": "2.x",
+        "dayjs": "1.x",
+        "moment": "^2.24.0",
+        "rc-trigger": "^5.0.4",
+        "rc-util": "^5.37.0",
+        "shallowequal": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz",
+      "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "classnames": "^2.2.6",
+        "rc-align": "^4.0.0",
+        "rc-motion": "^2.0.0",
+        "rc-util": "^5.19.2"
+      },
+      "engines": {
+        "node": ">=8.x"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
+    "node_modules/@rjsf/antd/node_modules/rc-picker/node_modules/rc-trigger/node_modules/rc-align": {
+      "version": "4.0.15",
+      "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz",
+      "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.10.1",
+        "classnames": "2.x",
+        "dom-align": "^1.7.0",
+        "rc-util": "^5.26.0",
+        "resize-observer-polyfill": "^1.5.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0"
+      }
+    },
     "node_modules/@rjsf/core": {
       "version": "5.24.13",
       "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.13.tgz",
@@ -22435,6 +22592,22 @@
       "integrity": "sha512-O/gRkjWULp3xVX8K85V0H3tsSGole0WYt77KVpGZO2xTGLuVFuvE6JIsIli3fvFHCYBhGFn/8OHEEyMYF+QehA==",
       "license": "MIT"
     },
+    "node_modules/date-fns": {
+      "version": "2.30.0",
+      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+      "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.21.0"
+      },
+      "engines": {
+        "node": ">=0.11"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/date-fns"
+      }
+    },
     "node_modules/dateformat": {
       "version": "3.0.2",
       "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.2.tgz",
@@ -23040,6 +23213,12 @@
       "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
       "license": "MIT"
     },
+    "node_modules/dom-align": {
+      "version": "1.12.4",
+      "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
+      "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
+      "license": "MIT"
+    },
     "node_modules/dom-converter": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -35677,6 +35856,12 @@
       "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
       "license": "MIT"
     },
+    "node_modules/lodash.isempty": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
+      "integrity": "sha512-oKMuF3xEeqDltrGMfDxAPGIVMSSRv8tbRSODbrs4KGsRRLEhrW8N8Rd4DRgB2+621hY8A8XwwrTVhXWpxFvMzg==",
+      "license": "MIT"
+    },
     "node_modules/lodash.isequal": {
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
@@ -35708,7 +35893,6 @@
       "version": "4.6.2",
       "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
       "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/lodash.once": {
@@ -35719,6 +35903,18 @@
       "license": "MIT",
       "peer": true
     },
+    "node_modules/lodash.range": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.range/-/lodash.range-3.2.0.tgz",
+      "integrity": "sha512-Fgkb7SinmuzqgIhNhAElo0BL/R1rHCnhwSZf78omqSwvWqD0kD2ssOAutQonDKH/ldS8BxA72ORYI09qAY9CYg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash.startcase": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz",
+      "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==",
+      "license": "MIT"
+    },
     "node_modules/lodash.uniq": {
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -37240,7 +37436,6 @@
       "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
       "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
       "license": "MIT",
-      "peer": true,
       "engines": {
         "node": "*"
       }
@@ -44766,6 +44961,12 @@
       "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==",
       "license": "MIT"
     },
+    "node_modules/shallowequal": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+      "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
+      "license": "MIT"
+    },
     "node_modules/shapefile": {
       "version": "0.3.1",
       "resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.3.1.tgz",
@@ -51505,7 +51706,7 @@
         "react-js-cron": "^5.2.0",
         "react-markdown": "^8.0.7",
         "react-resize-detector": "^7.1.2",
-        "react-syntax-highlighter": "^16.1.0",
+        "react-syntax-highlighter": "^16.1.1",
         "react-ultimate-pagination": "^1.3.2",
         "regenerator-runtime": "^0.14.1",
         "rehype-raw": "^7.0.0",
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index e5b6e7c5321..cd6c95ffcda 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -117,7 +117,14 @@
     "@luma.gl/gltf": "~9.2.5",
     "@luma.gl/shadertools": "~9.2.5",
     "@luma.gl/webgl": "~9.2.5",
+    "@fontsource/fira-code": "^5.2.7",
+    "@fontsource/inter": "^5.2.8",
+    "@great-expectations/jsonforms-antd-renderers": "^2.2.10",
+    "@jsonforms/core": "^3.7.0",
+    "@jsonforms/react": "^3.7.0",
+    "@jsonforms/vanilla-renderers": "^3.7.0",
     "@reduxjs/toolkit": "^1.9.3",
+    "@rjsf/antd": "^5.24.13",
     "@rjsf/core": "^5.24.13",
     "@rjsf/utils": "^5.24.3",
     "@rjsf/validator-ajv8": "^5.24.13",
diff --git a/superset-frontend/src/features/home/SubMenu.tsx b/superset-frontend/src/features/home/SubMenu.tsx
index f71892fb501..69eb9b2772a 100644
--- a/superset-frontend/src/features/home/SubMenu.tsx
+++ b/superset-frontend/src/features/home/SubMenu.tsx
@@ -150,6 +150,7 @@ export interface ButtonProps {
   buttonStyle: 'primary' | 'secondary' | 'dashed' | 'link' | 'tertiary';
   loading?: boolean;
   icon?: IconType;
+  component?: ReactNode;
 }
 
 export interface SubMenuProps {
@@ -300,18 +301,22 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
               </SubMenu>
             ))}
           </Menu>
-          {props.buttons?.map((btn, i) => (
-            <Button
-              key={i}
-              buttonStyle={btn.buttonStyle}
-              icon={btn.icon}
-              onClick={btn.onClick}
-              data-test={btn['data-test']}
-              loading={btn.loading ?? false}
-            >
-              {btn.name}
-            </Button>
-          ))}
+          {props.buttons?.map((btn, i) =>
+            btn.component ? (
+              <span key={i}>{btn.component}</span>
+            ) : (
+              <Button
+                key={i}
+                buttonStyle={btn.buttonStyle}
+                icon={btn.icon}
+                onClick={btn.onClick}
+                data-test={btn['data-test']}
+                loading={btn.loading ?? false}
+              >
+                {btn.name}
+              </Button>
+            ),
+          )}
         </div>
       </Row>
       {props.children}
diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
new file mode 100644
index 00000000000..7803138542f
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
@@ -0,0 +1,628 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
+import { SupersetClient } from '@superset-ui/core';
+import { Input, Spin } from 'antd';
+import { Select } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { JsonForms, withJsonFormsControlProps } from '@jsonforms/react';
+import type {
+  JsonSchema,
+  UISchemaElement,
+  ControlProps,
+} from '@jsonforms/core';
+import {
+  rankWith,
+  and,
+  isStringControl,
+  formatIs,
+  schemaMatches,
+} from '@jsonforms/core';
+import {
+  rendererRegistryEntries,
+  cellRegistryEntries,
+  TextControl,
+} from '@great-expectations/jsonforms-antd-renderers';
+import type { ErrorObject } from 'ajv';
+import {
+  StandardModal,
+  ModalFormField,
+  MODAL_STANDARD_WIDTH,
+  MODAL_MEDIUM_WIDTH,
+} from 'src/components/Modal';
+
+/**
+ * Custom renderer that renders `Input.Password` for fields with
+ * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
+ */
+function PasswordControl(props: ControlProps) {
+  const uischema = {
+    ...props.uischema,
+    options: { ...props.uischema.options, type: 'password' },
+  };
+  return TextControl({ ...props, uischema });
+}
+const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
+const passwordEntry = {
+  tester: rankWith(3, and(isStringControl, formatIs('password'))),
+  renderer: PasswordRenderer,
+};
+
+/**
+ * Renderer for `const` properties (e.g. Pydantic discriminator fields).
+ * Renders nothing visually but ensures the const value is set in form data,
+ * so discriminated unions resolve correctly on the backend.
+ */
+function ConstControl({ data, handleChange, path, schema }: ControlProps) {
+  const constValue = (schema as Record<string, unknown>).const;
+  useEffect(() => {
+    if (constValue !== undefined && data !== constValue) {
+      handleChange(path, constValue);
+    }
+  }, [constValue, data, handleChange, path]);
+  return null;
+}
+const ConstRenderer = withJsonFormsControlProps(ConstControl);
+const constEntry = {
+  tester: rankWith(
+    10,
+    schemaMatches(s => s !== undefined && 'const' in s),
+  ),
+  renderer: ConstRenderer,
+};
+
+/**
+ * Checks whether all dependency values are filled (non-empty).
+ * Handles nested objects (like auth) by checking they have at least one key.
+ */
+function areDependenciesSatisfied(
+  dependencies: string[],
+  data: Record<string, unknown>,
+): boolean {
+  return dependencies.every(dep => {
+    const value = data[dep];
+    if (value === null || value === undefined || value === '') return false;
+    if (typeof value === 'object' && Object.keys(value).length === 0)
+      return false;
+    return true;
+  });
+}
+
+/**
+ * Renderer for fields marked `x-dynamic` in the JSON Schema.
+ * Shows a loading spinner inside the input while the schema is being
+ * refreshed with dynamic values from the backend.
+ */
+function DynamicFieldControl(props: ControlProps) {
+  const { refreshingSchema, formData: cfgData } = props.config ?? {};
+  const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
+  const refreshing =
+    refreshingSchema &&
+    Array.isArray(deps) &&
+    areDependenciesSatisfied(
+      deps as string[],
+      (cfgData as Record<string, unknown>) ?? {},
+    );
+
+  if (!refreshing) {
+    return TextControl(props);
+  }
+
+  const uischema = {
+    ...props.uischema,
+    options: {
+      ...props.uischema.options,
+      placeholderText: t('Loading...'),
+      inputProps: { suffix: <Spin size="small" /> },
+    },
+  };
+  return TextControl({ ...props, uischema, enabled: false });
+}
+const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
+const dynamicFieldEntry = {
+  tester: rankWith(
+    3,
+    and(
+      isStringControl,
+      schemaMatches(
+        s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
+      ),
+    ),
+  ),
+  renderer: DynamicFieldRenderer,
+};
+
+const renderers = [
+  ...rendererRegistryEntries,
+  passwordEntry,
+  constEntry,
+  dynamicFieldEntry,
+];
+
+type Step = 'type' | 'config';
+type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
+
+const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
+
+/**
+ * Removes empty `enum` arrays from schema properties. The JSON Schema spec
+ * requires `enum` to have at least one item, and AJV rejects empty arrays.
+ * Fields with empty enums are rendered as plain text inputs instead.
+ */
+function sanitizeSchema(schema: JsonSchema): JsonSchema {
+  if (!schema.properties) return schema;
+  const properties: Record<string, JsonSchema> = {};
+  for (const [key, prop] of Object.entries(schema.properties)) {
+    if (
+      typeof prop === 'object' &&
+      prop !== null &&
+      'enum' in prop &&
+      Array.isArray(prop.enum) &&
+      prop.enum.length === 0
+    ) {
+      const { enum: _empty, ...rest } = prop;
+      properties[key] = rest;
+    } else {
+      properties[key] = prop as JsonSchema;
+    }
+  }
+  return { ...schema, properties } as JsonSchema;
+}
+
+/**
+ * Builds a JSON Forms UI schema from a JSON Schema, using the first
+ * `examples` entry as placeholder text for each string property.
+ */
+function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined {
+  if (!schema.properties) return undefined;
+
+  // Use explicit property order from backend if available,
+  // otherwise fall back to the JSON object key order
+  const propertyOrder: string[] =
+    ((schema as Record<string, unknown>)['x-propertyOrder'] as string[]) ??
+    Object.keys(schema.properties);
+
+  const elements = propertyOrder
+    .filter(key => key in (schema.properties ?? {}))
+    .map(key => {
+      const prop = schema.properties![key];
+      const control: Record<string, unknown> = {
+        type: 'Control',
+        scope: `#/properties/${key}`,
+      };
+      if (typeof prop === 'object' && prop !== null) {
+        const options: Record<string, unknown> = {};
+        if (
+          'examples' in prop &&
+          Array.isArray(prop.examples) &&
+          prop.examples.length > 0
+        ) {
+          options.placeholderText = String(prop.examples[0]);
+        }
+        if ('description' in prop && typeof prop.description === 'string') {
+          options.tooltip = prop.description;
+        }
+        if (Object.keys(options).length > 0) {
+          control.options = options;
+        }
+      }
+      return control;
+    });
+  return { type: 'VerticalLayout', elements } as UISchemaElement;
+}
+
+/**
+ * Extracts dynamic field dependency mappings from the schema.
+ * Returns a map of field name → list of dependency field names.
+ */
+function getDynamicDependencies(schema: JsonSchema): Record<string, string[]> {
+  const deps: Record<string, string[]> = {};
+  if (!schema.properties) return deps;
+  for (const [key, prop] of Object.entries(schema.properties)) {
+    if (
+      typeof prop === 'object' &&
+      prop !== null &&
+      'x-dynamic' in prop &&
+      'x-dependsOn' in prop &&
+      Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
+    ) {
+      deps[key] = (prop as Record<string, unknown>)['x-dependsOn'] as string[];
+    }
+  }
+  return deps;
+}
+
+/**
+ * Serializes the dependency values for a set of fields into a stable string
+ * for comparison, so we only re-fetch when dependency values actually change.
+ */
+function serializeDependencyValues(
+  dynamicDeps: Record<string, string[]>,
+  data: Record<string, unknown>,
+): string {
+  const allDepKeys = new Set<string>();
+  for (const deps of Object.values(dynamicDeps)) {
+    for (const dep of deps) {
+      allDepKeys.add(dep);
+    }
+  }
+  const snapshot: Record<string, unknown> = {};
+  for (const key of [...allDepKeys].sort()) {
+    snapshot[key] = data[key];
+  }
+  return JSON.stringify(snapshot);
+}
+
+const ModalContent = styled.div`
+  padding: ${({ theme }) => theme.sizeUnit * 4}px;
+`;
+
+const BackLink = styled.button`
+  background: none;
+  border: none;
+  color: ${({ theme }) => theme.colorPrimary};
+  cursor: pointer;
+  padding: 0;
+  font-size: ${({ theme }) => theme.fontSize}px;
+  margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
+  display: inline-flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+interface SemanticLayerType {
+  id: string;
+  name: string;
+  description: string;
+}
+
+interface SemanticLayerModalProps {
+  show: boolean;
+  onHide: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+  semanticLayerUuid?: string;
+}
+
+export default function SemanticLayerModal({
+  show,
+  onHide,
+  addDangerToast,
+  addSuccessToast,
+  semanticLayerUuid,
+}: SemanticLayerModalProps) {
+  const isEditMode = !!semanticLayerUuid;
+  const [step, setStep] = useState<Step>('type');
+  const [name, setName] = useState('');
+  const [selectedType, setSelectedType] = useState<string | null>(null);
+  const [types, setTypes] = useState<SemanticLayerType[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [configSchema, setConfigSchema] = useState<JsonSchema | null>(null);
+  const [uiSchema, setUiSchema] = useState<UISchemaElement | undefined>(
+    undefined,
+  );
+  const [formData, setFormData] = useState<Record<string, unknown>>({});
+  const [saving, setSaving] = useState(false);
+  const [hasErrors, setHasErrors] = useState(true);
+  const [refreshingSchema, setRefreshingSchema] = useState(false);
+  const [validationMode, setValidationMode] =
+    useState<ValidationMode>('ValidateAndHide');
+  const errorsRef = useRef<ErrorObject[]>([]);
+  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const lastDepSnapshotRef = useRef<string>('');
+  const dynamicDepsRef = useRef<Record<string, string[]>>({});
+
+  const fetchTypes = useCallback(async () => {
+    setLoading(true);
+    try {
+      const { json } = await SupersetClient.get({
+        endpoint: '/api/v1/semantic_layer/types',
+      });
+      setTypes(json.result ?? []);
+    } catch {
+      addDangerToast(
+        t('An error occurred while fetching semantic layer types'),
+      );
+    } finally {
+      setLoading(false);
+    }
+  }, [addDangerToast]);
+
+  const applySchema = useCallback((rawSchema: JsonSchema) => {
+    const schema = sanitizeSchema(rawSchema);
+    setConfigSchema(schema);
+    setUiSchema(buildUiSchema(schema));
+    dynamicDepsRef.current = getDynamicDependencies(rawSchema);
+  }, []);
+
+  const fetchConfigSchema = useCallback(
+    async (type: string, configuration?: Record<string, unknown>) => {
+      const isInitialFetch = !configuration;
+      if (isInitialFetch) setLoading(true);
+      else setRefreshingSchema(true);
+      try {
+        const { json } = await SupersetClient.post({
+          endpoint: '/api/v1/semantic_layer/schema/configuration',
+          jsonPayload: { type, configuration },
+        });
+        applySchema(json.result);
+        if (isInitialFetch) setStep('config');
+      } catch {
+        if (isInitialFetch) {
+          addDangerToast(
+            t('An error occurred while fetching the configuration schema'),
+          );
+        }
+      } finally {
+        if (isInitialFetch) setLoading(false);
+        else setRefreshingSchema(false);
+      }
+    },
+    [addDangerToast, applySchema],
+  );
+
+  const fetchExistingLayer = useCallback(
+    async (uuid: string) => {
+      setLoading(true);
+      try {
+        const { json } = await SupersetClient.get({
+          endpoint: `/api/v1/semantic_layer/${uuid}`,
+        });
+        const layer = json.result;
+        setName(layer.name ?? '');
+        setSelectedType(layer.type);
+        setFormData(layer.configuration ?? {});
+        setHasErrors(false);
+        // Fetch base schema (no configuration → no Snowflake connection) to
+        // show the form immediately. The existing maybeRefreshSchema machinery
+        // will trigger an enriched fetch in the background once deps are
+        // satisfied, and DynamicFieldControl will show per-field spinners.
+        const { json: schemaJson } = await SupersetClient.post({
+          endpoint: '/api/v1/semantic_layer/schema/configuration',
+          jsonPayload: { type: layer.type },
+        });
+        applySchema(schemaJson.result);
+        setStep('config');
+      } catch {
+        addDangerToast(
+          t('An error occurred while fetching the semantic layer'),
+        );
+      } finally {
+        setLoading(false);
+      }
+    },
+    [addDangerToast, applySchema],
+  );
+
+  useEffect(() => {
+    if (show) {
+      if (isEditMode && semanticLayerUuid) {
+        fetchTypes();
+        fetchExistingLayer(semanticLayerUuid);
+      } else {
+        fetchTypes();
+      }
+    } else {
+      setStep('type');
+      setName('');
+      setSelectedType(null);
+      setTypes([]);
+      setConfigSchema(null);
+      setUiSchema(undefined);
+      setFormData({});
+      setHasErrors(true);
+      setRefreshingSchema(false);
+      setValidationMode('ValidateAndHide');
+      errorsRef.current = [];
+      lastDepSnapshotRef.current = '';
+      dynamicDepsRef.current = {};
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    }
+  }, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]);
+
+  const handleStepAdvance = () => {
+    if (selectedType) {
+      fetchConfigSchema(selectedType);
+    }
+  };
+
+  const handleBack = () => {
+    setStep('type');
+    setConfigSchema(null);
+    setUiSchema(undefined);
+    setFormData({});
+    setValidationMode('ValidateAndHide');
+    errorsRef.current = [];
+    lastDepSnapshotRef.current = '';
+    dynamicDepsRef.current = {};
+    if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+  };
+
+  const handleCreate = async () => {
+    setSaving(true);
+    try {
+      if (isEditMode && semanticLayerUuid) {
+        await SupersetClient.put({
+          endpoint: `/api/v1/semantic_layer/${semanticLayerUuid}`,
+          jsonPayload: { name, configuration: formData },
+        });
+        addSuccessToast(t('Semantic layer updated'));
+      } else {
+        await SupersetClient.post({
+          endpoint: '/api/v1/semantic_layer/',
+          jsonPayload: { name, type: selectedType, configuration: formData },
+        });
+        addSuccessToast(t('Semantic layer created'));
+      }
+      onHide();
+    } catch {
+      addDangerToast(
+        isEditMode
+          ? t('An error occurred while updating the semantic layer')
+          : t('An error occurred while creating the semantic layer'),
+      );
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleSave = () => {
+    if (step === 'type') {
+      handleStepAdvance();
+    } else {
+      setValidationMode('ValidateAndShow');
+      if (errorsRef.current.length === 0) {
+        handleCreate();
+      }
+    }
+  };
+
+  const maybeRefreshSchema = useCallback(
+    (data: Record<string, unknown>) => {
+      if (!selectedType) return;
+
+      const dynamicDeps = dynamicDepsRef.current;
+      if (Object.keys(dynamicDeps).length === 0) return;
+
+      // Check if any dynamic field has all dependencies satisfied
+      const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
+        areDependenciesSatisfied(deps, data),
+      );
+      if (!hasSatisfiedDeps) return;
+
+      // Only re-fetch if dependency values actually changed
+      const snapshot = serializeDependencyValues(dynamicDeps, data);
+      if (snapshot === lastDepSnapshotRef.current) return;
+      lastDepSnapshotRef.current = snapshot;
+
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+      debounceTimerRef.current = setTimeout(() => {
+        fetchConfigSchema(selectedType, data);
+      }, SCHEMA_REFRESH_DEBOUNCE_MS);
+    },
+    [selectedType, fetchConfigSchema],
+  );
+
+  const handleFormChange = useCallback(
+    ({
+      data,
+      errors,
+    }: {
+      data: Record<string, unknown>;
+      errors?: ErrorObject[];
+    }) => {
+      setFormData(data);
+      errorsRef.current = errors ?? [];
+      setHasErrors(errorsRef.current.length > 0);
+      if (
+        validationMode === 'ValidateAndShow' &&
+        errorsRef.current.length === 0
+      ) {
+        handleCreate();
+      }
+      maybeRefreshSchema(data);
+    },
+    [validationMode, handleCreate, maybeRefreshSchema],
+  );
+
+  const selectedTypeName =
+    types.find(type => type.id === selectedType)?.name ?? '';
+
+  const title = isEditMode
+    ? t('Edit %s', selectedTypeName || t('Semantic Layer'))
+    : step === 'type'
+      ? t('New Semantic Layer')
+      : t('Configure %s', selectedTypeName);
+
+  return (
+    <StandardModal
+      show={show}
+      onHide={onHide}
+      onSave={handleSave}
+      title={title}
+      icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />}
+      width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH}
+      saveDisabled={
+        step === 'type' ? !selectedType : saving || !name.trim() || hasErrors
+      }
+      saveText={
+        step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')
+      }
+      saveLoading={saving}
+      contentLoading={loading}
+    >
+      {step === 'type' ? (
+        <ModalContent>
+          <ModalFormField label={t('Type')}>
+            <Select
+              ariaLabel={t('Semantic layer type')}
+              placeholder={t('Select a semantic layer type')}
+              value={selectedType}
+              onChange={value => setSelectedType(value as string)}
+              options={types.map(type => ({
+                value: type.id,
+                label: type.name,
+              }))}
+              getPopupContainer={() => document.body}
+              dropdownAlign={{
+                points: ['tl', 'bl'],
+                offset: [0, 4],
+                overflow: { adjustX: 0, adjustY: 1 },
+              }}
+            />
+          </ModalFormField>
+        </ModalContent>
+      ) : (
+        <ModalContent>
+          {!isEditMode && (
+            <BackLink type="button" onClick={handleBack}>
+              <Icons.CaretLeftOutlined iconSize="s" />
+              {t('Back')}
+            </BackLink>
+          )}
+          <ModalFormField label={t('Name')} required>
+            <Input
+              value={name}
+              onChange={e => setName(e.target.value)}
+              placeholder={t('Name of the semantic layer')}
+            />
+          </ModalFormField>
+          {configSchema && (
+            <JsonForms
+              schema={configSchema}
+              uischema={uiSchema}
+              data={formData}
+              renderers={renderers}
+              cells={cellRegistryEntries}
+              config={{ refreshingSchema, formData }}
+              validationMode={validationMode}
+              onChange={handleFormChange}
+            />
+          )}
+        </ModalContent>
+      )}
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index d382cb6f496..a263fe2d2b4 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -17,8 +17,13 @@
  * under the License.
  */
 import { t } from '@apache-superset/core/translation';
-import { getExtensionsRegistry, SupersetClient } from '@superset-ui/core';
-import { styled } from '@apache-superset/core/theme';
+import {
+  getExtensionsRegistry,
+  SupersetClient,
+  isFeatureEnabled,
+  FeatureFlag,
+} from '@superset-ui/core';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
 import { useState, useMemo, useEffect, useCallback } from 'react';
 import rison from 'rison';
 import { useSelector } from 'react-redux';
@@ -33,7 +38,9 @@ import {
 import withToasts from 'src/components/MessageToasts/withToasts';
 import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
 import {
+  Button,
   DeleteModal,
+  Dropdown,
   Tooltip,
   List,
   Loading,
@@ -43,6 +50,7 @@ import {
   ListView,
   ListViewFilterOperator as FilterOperator,
   ListViewFilters,
+  type ListViewFetchDataConfig,
 } from 'src/components';
 import { Typography } from '@superset-ui/core/components/Typography';
 import { getUrlParam } from 'src/utils/urlUtils';
@@ -55,6 +63,7 @@ import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
 import type { MenuObjectProps } from 'src/types/bootstrapTypes';
 import DatabaseModal from 'src/features/databases/DatabaseModal';
 import UploadDataModal from 'src/features/databases/UploadDataModel';
+import SemanticLayerModal from 'src/features/semanticLayers/SemanticLayerModal';
 import { DatabaseObject } from 'src/features/databases/types';
 import { QueryObjectColumns } from 'src/views/CRUD/types';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -70,6 +79,11 @@ const dbConfigExtraExtension = extensionsRegistry.get(
 
 const PAGE_SIZE = 25;
 
+type ConnectionItem = DatabaseObject & {
+  source_type?: 'database' | 'semantic_layer';
+  sl_type?: string;
+};
+
 interface DatabaseDeleteObject extends DatabaseObject {
   charts: any;
   dashboards: any;
@@ -108,20 +122,106 @@ function DatabaseList({
   addSuccessToast,
   user,
 }: DatabaseListProps) {
+  const theme = useTheme();
+  const showSemanticLayers = isFeatureEnabled(FeatureFlag.SemanticLayers);
+
+  // Standard database list view resource (used when SL flag is OFF)
   const {
     state: {
-      loading,
-      resourceCount: databaseCount,
-      resourceCollection: databases,
+      loading: dbLoading,
+      resourceCount: dbCount,
+      resourceCollection: dbCollection,
     },
     hasPerm,
-    fetchData,
-    refreshData,
+    fetchData: dbFetchData,
+    refreshData: dbRefreshData,
   } = useListViewResource<DatabaseObject>(
     'database',
     t('database'),
     addDangerToast,
   );
+
+  // Combined endpoint state (used when SL flag is ON)
+  const [combinedItems, setCombinedItems] = useState<ConnectionItem[]>([]);
+  const [combinedCount, setCombinedCount] = useState(0);
+  const [combinedLoading, setCombinedLoading] = useState(true);
+  const [lastFetchConfig, setLastFetchConfig] =
+    useState<ListViewFetchDataConfig | null>(null);
+
+  const combinedFetchData = useCallback(
+    (config: ListViewFetchDataConfig) => {
+      setLastFetchConfig(config);
+      setCombinedLoading(true);
+      const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
+
+      const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
+      const otherFilters = filterValues
+        .filter(f => f.id !== 'source_type')
+        .filter(
+          ({ value }) => value !== '' && value !== null && value !== undefined,
+        )
+        .map(({ id, operator: opr, value }) => ({
+          col: id,
+          opr,
+          value:
+            value && typeof value === 'object' && 'value' in value
+              ? value.value
+              : value,
+        }));
+
+      const sourceTypeValue =
+        sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object'
+          ? (sourceTypeFilter.value as { value: string }).value
+          : (sourceTypeFilter?.value as string | undefined);
+      if (sourceTypeValue) {
+        otherFilters.push({
+          col: 'source_type',
+          opr: 'eq',
+          value: sourceTypeValue,
+        });
+      }
+
+      const queryParams = rison.encode_uri({
+        order_column: sortBy[0].id,
+        order_direction: sortBy[0].desc ? 'desc' : 'asc',
+        page: pageIndex,
+        page_size: pageSize,
+        ...(otherFilters.length ? { filters: otherFilters } : {}),
+      });
+
+      return SupersetClient.get({
+        endpoint: `/api/v1/semantic_layer/connections/?q=${queryParams}`,
+      })
+        .then(({ json = {} }) => {
+          setCombinedItems(json.result);
+          setCombinedCount(json.count);
+        })
+        .catch(() => {
+          addDangerToast(t('An error occurred while fetching connections'));
+        })
+        .finally(() => {
+          setCombinedLoading(false);
+        });
+    },
+    [addDangerToast],
+  );
+
+  const combinedRefreshData = useCallback(() => {
+    if (lastFetchConfig) {
+      return combinedFetchData(lastFetchConfig);
+    }
+    return undefined;
+  }, [lastFetchConfig, combinedFetchData]);
+
+  // Select the right data source based on feature flag
+  const loading = showSemanticLayers ? combinedLoading : dbLoading;
+  const databaseCount = showSemanticLayers ? combinedCount : dbCount;
+  const databases: ConnectionItem[] = showSemanticLayers
+    ? combinedItems
+    : dbCollection;
+  const fetchData = showSemanticLayers ? combinedFetchData : dbFetchData;
+  const refreshData = showSemanticLayers ? combinedRefreshData : dbRefreshData;
+
   const fullUser = useSelector<any, UserWithPermissionsAndRoles>(
     state => state.user,
   );
@@ -148,6 +248,13 @@ function DatabaseList({
     useState<boolean>(false);
   const [columnarUploadDataModalOpen, setColumnarUploadDataModalOpen] =
     useState<boolean>(false);
+  const [semanticLayerModalOpen, setSemanticLayerModalOpen] =
+    useState<boolean>(false);
+  const [slCurrentlyEditing, setSlCurrentlyEditing] = useState<string | null>(
+    null,
+  );
+  const [slCurrentlyDeleting, setSlCurrentlyDeleting] =
+    useState<ConnectionItem | null>(null);
 
   const [allowUploads, setAllowUploads] = useState<boolean>(false);
   const isAdmin = isUserAdmin(fullUser);
@@ -320,18 +427,63 @@ function DatabaseList({
   };
 
   if (canCreate) {
-    menuData.buttons = [
-      {
-        'data-test': 'btn-create-database',
-        icon: <Icons.PlusOutlined iconSize="m" />,
-        name: t('Database'),
-        buttonStyle: 'primary',
-        onClick: () => {
-          // Ensure modal will be opened in add mode
-          handleDatabaseEditModal({ modalOpen: true });
+    const openDatabaseModal = () =>
+      handleDatabaseEditModal({ modalOpen: true });
+
+    if (isFeatureEnabled(FeatureFlag.SemanticLayers)) {
+      menuData.buttons = [
+        {
+          name: t('New'),
+          buttonStyle: 'primary',
+          component: (
+            <Dropdown
+              menu={{
+                items: [
+                  {
+                    key: 'database',
+                    label: t('Database'),
+                    onClick: openDatabaseModal,
+                  },
+                  {
+                    key: 'semantic-layer',
+                    label: t('Semantic Layer'),
+                    onClick: () => {
+                      setSemanticLayerModalOpen(true);
+                    },
+                  },
+                ],
+              }}
+              trigger={['click']}
+            >
+              <Button
+                data-test="btn-create-new"
+                buttonStyle="primary"
+                icon={<Icons.PlusOutlined iconSize="m" />}
+              >
+                {t('New')}
+                <Icons.DownOutlined
+                  iconSize="s"
+                  css={css`
+                    margin-left: ${theme.sizeUnit * 1.5}px;
+                    margin-right: -${theme.sizeUnit * 2}px;
+                  `}
+                />
+              </Button>
+            </Dropdown>
+          ),
         },
-      },
-    ];
+      ];
+    } else {
+      menuData.buttons = [
+        {
+          'data-test': 'btn-create-database',
+          icon: <Icons.PlusOutlined iconSize="m" />,
+          name: t('Database'),
+          buttonStyle: 'primary',
+          onClick: openDatabaseModal,
+        },
+      ];
+    }
   }
 
   const handleDatabaseExport = useCallback(
@@ -401,6 +553,23 @@ function DatabaseList({
 
   const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
 
+  function handleSemanticLayerDelete(item: ConnectionItem) {
+    SupersetClient.delete({
+      endpoint: `/api/v1/semantic_layer/${item.uuid}`,
+    }).then(
+      () => {
+        refreshData();
+        addSuccessToast(t('Deleted: %s', item.database_name));
+        setSlCurrentlyDeleting(null);
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting %s: %s', item.database_name, errMsg),
+        ),
+      ),
+    );
+  }
+
   const columns = useMemo(
     () => [
       {
@@ -413,7 +582,7 @@ function DatabaseList({
         accessor: 'backend',
         Header: t('Backend'),
         size: 'xl',
-        disableSortBy: true, // TODO: api support for sorting by 'backend'
+        disableSortBy: true,
         id: 'backend',
       },
       {
@@ -427,13 +596,12 @@ function DatabaseList({
             <span>{t('AQE')}</span>
           </Tooltip>
         ),
-        Cell: ({
-          row: {
-            original: { allow_run_async: allowRunAsync },
-          },
-        }: {
-          row: { original: { allow_run_async: boolean } };
-        }) => <BooleanDisplay value={allowRunAsync} />,
+        Cell: ({ row: { original } }: any) =>
+          original.source_type === 'semantic_layer' ? (
+            <span>–</span>
+          ) : (
+            <BooleanDisplay value={original.allow_run_async} />
+          ),
         size: 'sm',
         id: 'allow_run_async',
       },
@@ -448,33 +616,36 @@ function DatabaseList({
             <span>{t('DML')}</span>
           </Tooltip>
         ),
-        Cell: ({
-          row: {
-            original: { allow_dml: allowDML },
-          },
-        }: any) => <BooleanDisplay value={allowDML} />,
+        Cell: ({ row: { original } }: any) =>
+          original.source_type === 'semantic_layer' ? (
+            <span>–</span>
+          ) : (
+            <BooleanDisplay value={original.allow_dml} />
+          ),
         size: 'sm',
         id: 'allow_dml',
       },
       {
         accessor: 'allow_file_upload',
         Header: t('File upload'),
-        Cell: ({
-          row: {
-            original: { allow_file_upload: allowFileUpload },
-          },
-        }: any) => <BooleanDisplay value={allowFileUpload} />,
+        Cell: ({ row: { original } }: any) =>
+          original.source_type === 'semantic_layer' ? (
+            <span>–</span>
+          ) : (
+            <BooleanDisplay value={original.allow_file_upload} />
+          ),
         size: 'md',
         id: 'allow_file_upload',
       },
       {
         accessor: 'expose_in_sqllab',
         Header: t('Expose in SQL Lab'),
-        Cell: ({
-          row: {
-            original: { expose_in_sqllab: exposeInSqllab },
-          },
-        }: any) => <BooleanDisplay value={exposeInSqllab} />,
+        Cell: ({ row: { original } }: any) =>
+          original.source_type === 'semantic_layer' ? (
+            <span>–</span>
+          ) : (
+            <BooleanDisplay value={original.expose_in_sqllab} />
+          ),
         size: 'md',
         id: 'expose_in_sqllab',
       },
@@ -494,6 +665,48 @@ function DatabaseList({
       },
       {
         Cell: ({ row: { original } }: any) => {
+          const isSemanticLayer = original.source_type === 'semantic_layer';
+
+          if (isSemanticLayer) {
+            if (!canEdit && !canDelete) return null;
+            return (
+              <Actions className="actions">
+                {canDelete && (
+                  <Tooltip
+                    id="delete-action-tooltip"
+                    title={t('Delete')}
+                    placement="bottom"
+                  >
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => setSlCurrentlyDeleting(original)}
+                    >
+                      <Icons.DeleteOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+                {canEdit && (
+                  <Tooltip
+                    id="edit-action-tooltip"
+                    title={t('Edit')}
+                    placement="bottom"
+                  >
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => setSlCurrentlyEditing(original.uuid)}
+                    >
+                      <Icons.EditOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+              </Actions>
+            );
+          }
+
           const handleEdit = () =>
             handleDatabaseEditModal({ database: original, modalOpen: true });
           const handleDelete = () => openDatabaseDeleteModal(original);
@@ -579,6 +792,12 @@ function DatabaseList({
         hidden: !canEdit && !canDelete,
         disableSortBy: true,
       },
+      {
+        accessor: 'source_type',
+        hidden: true,
+        disableSortBy: true,
+        id: 'source_type',
+      },
       {
         accessor: QueryObjectColumns.ChangedBy,
         hidden: true,
@@ -596,8 +815,8 @@ function DatabaseList({
     ],
   );
 
-  const filters: ListViewFilters = useMemo(
-    () => [
+  const filters: ListViewFilters = useMemo(() => {
+    const baseFilters: ListViewFilters = [
       {
         Header: t('Name'),
         key: 'search',
@@ -605,62 +824,83 @@ function DatabaseList({
         input: 'search',
         operator: FilterOperator.Contains,
       },
-      {
-        Header: t('Expose in SQL Lab'),
-        key: 'expose_in_sql_lab',
-        id: 'expose_in_sqllab',
-        input: 'select',
-        operator: FilterOperator.Equals,
-        unfilteredLabel: t('All'),
-        selects: [
-          { label: t('Yes'), value: true },
-          { label: t('No'), value: false },
-        ],
-      },
-      {
-        Header: (
-          <Tooltip
-            id="allow-run-async-filter-header-tooltip"
-            title={t('Asynchronous query execution')}
-            placement="top"
-          >
-            <span>{t('AQE')}</span>
-          </Tooltip>
-        ),
-        key: 'allow_run_async',
-        id: 'allow_run_async',
+    ];
+
+    if (showSemanticLayers) {
+      baseFilters.push({
+        Header: t('Source'),
+        key: 'source_type',
+        id: 'source_type',
         input: 'select',
         operator: FilterOperator.Equals,
         unfilteredLabel: t('All'),
         selects: [
-          { label: t('Yes'), value: true },
-          { label: t('No'), value: false },
+          { label: t('Database'), value: 'database' },
+          { label: t('Semantic Layer'), value: 'semantic_layer' },
         ],
-      },
-      {
-        Header: t('Modified by'),
-        key: 'changed_by',
-        id: 'changed_by',
-        input: 'select',
-        operator: FilterOperator.RelationOneMany,
-        unfilteredLabel: t('All'),
-        fetchSelects: createFetchRelated(
-          'database',
-          'changed_by',
-          createErrorHandler(errMsg =>
-            t(
-              'An error occurred while fetching dataset datasource values: %s',
-              errMsg,
+      });
+    }
+
+    if (!showSemanticLayers) {
+      baseFilters.push(
+        {
+          Header: t('Expose in SQL Lab'),
+          key: 'expose_in_sql_lab',
+          id: 'expose_in_sqllab',
+          input: 'select',
+          operator: FilterOperator.Equals,
+          unfilteredLabel: t('All'),
+          selects: [
+            { label: t('Yes'), value: true },
+            { label: t('No'), value: false },
+          ],
+        },
+        {
+          Header: (
+            <Tooltip
+              id="allow-run-async-filter-header-tooltip"
+              title={t('Asynchronous query execution')}
+              placement="top"
+            >
+              <span>{t('AQE')}</span>
+            </Tooltip>
+          ),
+          key: 'allow_run_async',
+          id: 'allow_run_async',
+          input: 'select',
+          operator: FilterOperator.Equals,
+          unfilteredLabel: t('All'),
+          selects: [
+            { label: t('Yes'), value: true },
+            { label: t('No'), value: false },
+          ],
+        },
+        {
+          Header: t('Modified by'),
+          key: 'changed_by',
+          id: 'changed_by',
+          input: 'select',
+          operator: FilterOperator.RelationOneMany,
+          unfilteredLabel: t('All'),
+          fetchSelects: createFetchRelated(
+            'database',
+            'changed_by',
+            createErrorHandler(errMsg =>
+              t(
+                'An error occurred while fetching dataset datasource values: %s',
+                errMsg,
+              ),
             ),
+            user,
           ),
-          user,
-        ),
-        paginate: true,
-        dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
-      },
-    ],
-    [user],
-  );
+          paginate: true,
+          dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
+        },
+      );
+    }
+
+    return baseFilters;
+  }, [showSemanticLayers]);
 
   return (
     <>
@@ -703,6 +943,48 @@ function DatabaseList({
         allowedExtensions={COLUMNAR_EXTENSIONS}
         type="columnar"
       />
+      <SemanticLayerModal
+        show={semanticLayerModalOpen}
+        onHide={() => {
+          setSemanticLayerModalOpen(false);
+          refreshData();
+        }}
+        addDangerToast={addDangerToast}
+        addSuccessToast={addSuccessToast}
+      />
+      <SemanticLayerModal
+        show={!!slCurrentlyEditing}
+        onHide={() => {
+          setSlCurrentlyEditing(null);
+          refreshData();
+        }}
+        addDangerToast={addDangerToast}
+        addSuccessToast={addSuccessToast}
+        semanticLayerUuid={slCurrentlyEditing ?? undefined}
+      />
+      {slCurrentlyDeleting && (
+        <DeleteModal
+          description={
+            <p>
+              {t('Are you sure you want to delete')}{' '}
+              <b>{slCurrentlyDeleting.database_name}</b>?
+            </p>
+          }
+          onConfirm={() => {
+            if (slCurrentlyDeleting) {
+              handleSemanticLayerDelete(slCurrentlyDeleting);
+            }
+          }}
+          onHide={() => setSlCurrentlyDeleting(null)}
+          open
+          title={
+            <ModalTitleWithIcon
+              icon={<Icons.DeleteOutlined />}
+              title={t('Delete Semantic Layer?')}
+            />
+          }
+        />
+      )}
       {databaseCurrentlyDeleting && (
         <DeleteModal
           description={
diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py
index 48ec0603c2a..38111e07c78 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -30,6 +30,7 @@ from superset.commands.semantic_layer.exceptions import (
 )
 from superset.daos.semantic_layer import SemanticLayerDAO
 from superset.semantic_layers.registry import registry
+from superset.utils import json
 from superset.utils.decorators import on_error, transaction
 
 logger = logging.getLogger(__name__)
@@ -48,6 +49,10 @@ class CreateSemanticLayerCommand(BaseCommand):
     )
     def run(self) -> Model:
         self.validate()
+        if isinstance(self._properties.get("configuration"), dict):
+            self._properties["configuration"] = json.dumps(
+                self._properties["configuration"]
+            )
         return SemanticLayerDAO.create(attributes=self._properties)
 
     def validate(self) -> None:
diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/update.py
index 7487e17996a..2b776a11f0c 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/update.py
@@ -105,6 +105,10 @@ class UpdateSemanticLayerCommand(BaseCommand):
     def run(self) -> Model:
         self.validate()
         assert self._model
+        if isinstance(self._properties.get("configuration"), dict):
+            self._properties["configuration"] = json.dumps(
+                self._properties["configuration"]
+            )
         return SemanticLayerDAO.update(self._model, attributes=self._properties)
 
     def validate(self) -> None:
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index acb278cdf13..14527ffcf8a 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -19,12 +19,14 @@ from __future__ import annotations
 import logging
 from typing import Any
 
-from flask import request, Response
-from flask_appbuilder.api import expose, protect, safe
+from flask import make_response, request, Response
+from flask_appbuilder.api import expose, protect, rison, safe
+from flask_appbuilder.api.schemas import get_list_schema
 from flask_appbuilder.models.sqla.interface import SQLAInterface
 from marshmallow import ValidationError
+from pydantic import ValidationError as PydanticValidationError
 
-from superset import event_logger
+from superset import db, event_logger, is_feature_enabled
 from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
 from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
 from superset.commands.semantic_layer.exceptions import (
@@ -44,6 +46,7 @@ from superset.commands.semantic_layer.update import (
 )
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
 from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.models.core import Database
 from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.semantic_layers.registry import registry
 from superset.semantic_layers.schemas import (
@@ -52,6 +55,7 @@ from superset.semantic_layers.schemas import (
     SemanticViewPutSchema,
 )
 from superset.superset_typing import FlaskResponse
+from superset.utils import json
 from superset.views.base_api import (
     BaseSupersetApi,
     BaseSupersetModelRestApi,
@@ -63,15 +67,93 @@ logger = logging.getLogger(__name__)
 
 
 def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]:
+    config = layer.configuration
+    if isinstance(config, str):
+        config = json.loads(config)
     return {
         "uuid": str(layer.uuid),
         "name": layer.name,
         "description": layer.description,
         "type": layer.type,
         "cache_timeout": layer.cache_timeout,
+        "configuration": config or {},
+        "changed_on_delta_humanized": layer.changed_on_delta_humanized(),
     }
 
 
+def _infer_discriminators(
+    schema: dict[str, Any],
+    data: dict[str, Any],
+) -> dict[str, Any]:
+    """
+    Infer discriminator values for union fields when the frontend omits them.
+
+    Walks the schema's properties looking for discriminated unions (fields with a
+    ``discriminator.mapping``). For each one, tries to match the submitted data
+    against one of the variants by checking which variant's required fields are
+    present, then injects the discriminator value.
+    """
+    defs = schema.get("$defs", {})
+    for prop_name, prop_schema in schema.get("properties", {}).items():
+        value = data.get(prop_name)
+        if not isinstance(value, dict):
+            continue
+
+        # Find discriminated union via discriminator mapping
+        mapping = (
+            prop_schema.get("discriminator", {}).get("mapping")
+            if "discriminator" in prop_schema
+            else None
+        )
+        if not mapping:
+            continue
+
+        discriminator_field = prop_schema["discriminator"].get("propertyName")
+        if not discriminator_field or discriminator_field in value:
+            continue
+
+        # Try each variant: match by required fields present in the data
+        for disc_value, ref in mapping.items():
+            ref_name = ref.rsplit("/", 1)[-1] if "/" in ref else ref
+            variant_def = defs.get(ref_name, {})
+            required = set(variant_def.get("required", []))
+            # Exclude the discriminator itself from the check
+            required.discard(discriminator_field)
+            if required and required.issubset(value.keys()):
+                data = {
+                    **data,
+                    prop_name: {**value, discriminator_field: disc_value},
+                }
+                break
+
+    return data
+
+
+def _parse_partial_config(
+    cls: Any,
+    config: dict[str, Any],
+) -> Any:
+    """
+    Parse a partial configuration, handling discriminator inference and
+    falling back to lenient validation when strict parsing fails.
+    """
+    config_class = cls.configuration_class
+
+    # Infer discriminator values the frontend may have omitted
+    schema = config_class.model_json_schema()
+    config = _infer_discriminators(schema, config)
+
+    try:
+        return config_class.model_validate(config)
+    except (PydanticValidationError, ValueError):
+        pass
+
+    try:
+        return config_class.model_validate(config, context={"partial": True})
+    except (PydanticValidationError, ValueError):
+        return None
+
+
 class SemanticViewRestApi(BaseSupersetModelRestApi):
     datamodel = SQLAInterface(SemanticView)
 
@@ -230,13 +312,18 @@ class SemanticLayerRestApi(BaseSupersetApi):
 
         parsed_config = None
         if config := body.get("configuration"):
-            try:
-                parsed_config = cls.from_configuration(config).configuration  # type: ignore[attr-defined]
-            except Exception:  # pylint: disable=broad-except
-                parsed_config = None
+            parsed_config = _parse_partial_config(cls, config)
 
-        schema = cls.get_configuration_schema(parsed_config)
-        return self.response(200, result=schema)
+        try:
+            schema = cls.get_configuration_schema(parsed_config)
+        except Exception:  # pylint: disable=broad-except
+            # Connection or query failures during schema enrichment should not
+            # prevent the form from rendering — return the base schema instead.
+            schema = cls.get_configuration_schema(None)
+
+        resp = make_response(json.dumps({"result": schema}, sort_keys=False), 200)
+        resp.headers["Content-Type"] = "application/json; charset=utf-8"
+        return resp
 
     @expose("/<uuid>/schema/runtime", methods=("POST",))
     @protect()
@@ -443,6 +530,176 @@ class SemanticLayerRestApi(BaseSupersetApi):
             )
             return self.response_422(message=str(ex))
 
+    @expose("/connections/", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @rison(get_list_schema)
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.connections",
+        log_to_statsd=False,
+    )
+    def connections(self, **kwargs: Any) -> FlaskResponse:
+        """List databases and semantic layers combined.
+        ---
+        get:
+          summary: List databases and semantic layers combined
+          parameters:
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/get_list_schema'
+          responses:
+            200:
+              description: Combined list of databases and semantic layers
+            401:
+              $ref: '#/components/responses/401'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        args = kwargs.get("rison", {})
+        page = args.get("page", 0)
+        page_size = args.get("page_size", 25)
+        order_column = args.get("order_column", "changed_on")
+        order_direction = args.get("order_direction", "desc")
+        filters = args.get("filters", [])
+
+        source_type, name_filter = self._parse_connection_filters(filters)
+
+        if not is_feature_enabled("SEMANTIC_LAYERS"):
+            source_type = "database"
+
+        all_items = self._fetch_connection_items(source_type, name_filter)
+
+        sort_key = self._get_connection_sort_key(order_column)
+        all_items.sort(key=sort_key, reverse=order_direction == "desc")  # type: ignore
+        total_count = len(all_items)
+
+        start = page * page_size
+        page_items = all_items[start : start + page_size]
+
+        result = [
+            self._serialize_database(obj)
+            if item_type == "database"
+            else self._serialize_semantic_layer(obj)
+            for item_type, obj in page_items
+        ]
+
+        return self.response(200, count=total_count, result=result)
+
+    @staticmethod
+    def _parse_connection_filters(
+        filters: list[dict[str, Any]],
+    ) -> tuple[str, str | None]:
+        """Parse filters into source_type and name_filter."""
+        source_type = "all"
+        name_filter = None
+        for f in filters:
+            if f.get("col") == "source_type":
+                source_type = f.get("value", "all")
+            elif f.get("col") == "database_name" and f.get("opr") == "ct":
+                name_filter = f.get("value")
+        return source_type, name_filter
+
+    @staticmethod
+    def _fetch_connection_items(
+        source_type: str,
+        name_filter: str | None,
+    ) -> list[tuple[str, Any]]:
+        """Fetch database and semantic layer items based on filters."""
+        db_items: list[tuple[str, Database]] = []
+        if source_type in ("all", "database"):
+            db_q = db.session.query(Database)
+            if name_filter:
+                db_q = db_q.filter(Database.database_name.ilike(f"%{name_filter}%"))
+            db_items = [("database", obj) for obj in db_q.all()]
+
+        sl_items: list[tuple[str, SemanticLayer]] = []
+        if source_type in ("all", "semantic_layer"):
+            sl_q = db.session.query(SemanticLayer)
+            if name_filter:
+                sl_q = sl_q.filter(SemanticLayer.name.ilike(f"%{name_filter}%"))
+            sl_items = [("semantic_layer", obj) for obj in sl_q.all()]
+
+        return db_items + sl_items  # type: ignore
+
+    @staticmethod
+    def _get_connection_sort_key(order_column: str) -> Any:
+        """Return a sort key function for connection items."""
+
+        def _sort_key_changed_on(
+            item: tuple[str, Database | SemanticLayer],
+        ) -> float:
+            changed_on = item[1].changed_on
+            return changed_on.timestamp() if changed_on else 0.0
+
+        def _sort_key_name(
+            item: tuple[str, Database | SemanticLayer],
+        ) -> str:
+            obj = item[1]
+            raw = (
+                obj.database_name  # type: ignore[union-attr]
+                if item[0] == "database"
+                else obj.name
+            )
+            return raw.lower()
+
+        sort_key_map = {
+            "changed_on_delta_humanized": _sort_key_changed_on,
+            "database_name": _sort_key_name,
+        }
+        return sort_key_map.get(order_column, _sort_key_changed_on)
+
+    @staticmethod
+    def _serialize_database(obj: Database) -> dict[str, Any]:
+        changed_by = obj.changed_by
+        return {
+            "source_type": "database",
+            "id": obj.id,
+            "uuid": str(obj.uuid),
+            "database_name": obj.database_name,
+            "backend": obj.backend,
+            "allow_run_async": obj.allow_run_async,
+            "allow_dml": obj.allow_dml,
+            "allow_file_upload": obj.allow_file_upload,
+            "expose_in_sqllab": obj.expose_in_sqllab,
+            "changed_on_delta_humanized": obj.changed_on_delta_humanized(),
+            "changed_by": {
+                "first_name": changed_by.first_name,
+                "last_name": changed_by.last_name,
+            }
+            if changed_by
+            else None,
+        }
+
+    @staticmethod
+    def _serialize_semantic_layer(obj: SemanticLayer) -> dict[str, Any]:
+        changed_by = obj.changed_by
+        sl_type = obj.type
+        cls = registry.get(sl_type)
+        type_name = cls.name if cls else sl_type  # type: ignore[attr-defined]
+        return {
+            "source_type": "semantic_layer",
+            "uuid": str(obj.uuid),
+            "database_name": obj.name,
+            "backend": type_name,
+            "sl_type": sl_type,
+            "description": obj.description,
+            "allow_run_async": None,
+            "allow_dml": None,
+            "allow_file_upload": None,
+            "expose_in_sqllab": None,
+            "changed_on_delta_humanized": obj.changed_on_delta_humanized(),
+            "changed_by": {
+                "first_name": changed_by.first_name,
+                "last_name": changed_by.last_name,
+            }
+            if changed_by
+            else None,
+        }
+
     @expose("/", methods=("GET",))
     @protect()
     @safe
diff --git a/tests/unit_tests/commands/semantic_layer/create_test.py b/tests/unit_tests/commands/semantic_layer/create_test.py
index 3beceebc3b8..ca4bc0f82a2 100644
--- a/tests/unit_tests/commands/semantic_layer/create_test.py
+++ b/tests/unit_tests/commands/semantic_layer/create_test.py
@@ -51,7 +51,8 @@ def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
     result = CreateSemanticLayerCommand(data).run()
 
     assert result == new_model
-    dao.create.assert_called_once_with(attributes=data)
+    expected = {**data, "configuration": '{"account": "test"}'}
+    dao.create.assert_called_once_with(attributes=expected)
     mock_cls.from_configuration.assert_called_once_with({"account": "test"})
 
 
diff --git a/tests/unit_tests/commands/semantic_layer/update_test.py b/tests/unit_tests/commands/semantic_layer/update_test.py
index 1b144428e89..3bf85738edf 100644
--- a/tests/unit_tests/commands/semantic_layer/update_test.py
+++ b/tests/unit_tests/commands/semantic_layer/update_test.py
@@ -221,6 +221,116 @@ def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
     assert original_data == {"description": "Original"}
 
 
+# =============================================================================
+# UpdateSemanticLayerCommand tests
+# =============================================================================
+
+
+def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
+    """Test successful update of a semantic layer."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    data = {"name": "Updated", "description": "New desc"}
+    result = UpdateSemanticLayerCommand("some-uuid", data).run()
+
+    assert result == mock_model
+    dao.find_by_uuid.assert_called_once_with("some-uuid")
+    dao.update.assert_called_once_with(mock_model, attributes=data)
+
+
+def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerNotFoundError is raised when model is missing."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = None
+
+    with pytest.raises(SemanticLayerNotFoundError):
+        UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
+
+
+def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerInvalidError is raised for duplicate names."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.validate_update_uniqueness.return_value = False
+
+    with pytest.raises(SemanticLayerInvalidError):
+        UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
+
+
+def test_update_semantic_layer_validates_configuration(
+    mocker: MockerFixture,
+) -> None:
+    """Test that configuration is validated against the plugin."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    mock_cls = MagicMock()
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.update.registry",
+        {"snowflake": mock_cls},
+    )
+
+    config = {"account": "test"}
+    UpdateSemanticLayerCommand("some-uuid", {"configuration": config}).run()
+
+    mock_cls.from_configuration.assert_called_once_with(config)
+
+
+def test_update_semantic_layer_skips_name_check_when_no_name(
+    mocker: MockerFixture,
+) -> None:
+    """Test that name uniqueness is not checked when name is not provided."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
+
+    dao.validate_update_uniqueness.assert_not_called()
+
+
+def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
+    """Test that the command copies input data and does not mutate it."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    original_data = {"description": "Original"}
+    UpdateSemanticLayerCommand("some-uuid", original_data).run()
+
+    assert original_data == {"description": "Original"}
+
+
 def _make_view_model(
     uuid: str = "view-uuid-1",
     name: str = "my_view",
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index 08e1a1ab796..c6482c0f139 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -353,11 +353,14 @@ def test_configuration_schema_with_partial_config(
     mocker: MockerFixture,
 ) -> None:
     """Test POST /schema/configuration enriches schema with partial config."""
-    mock_instance = MagicMock()
-    mock_instance.configuration = {"account": "test"}
+    mock_config_obj = MagicMock()
 
     mock_cls = MagicMock()
-    mock_cls.from_configuration.return_value = mock_instance
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "type": "object",
+        "properties": {"account": {"type": "string"}},
+    }
+    mock_cls.configuration_class.model_validate.return_value = mock_config_obj
     mock_cls.get_configuration_schema.return_value = {
         "type": "object",
         "properties": {"database": {"enum": ["db1", "db2"]}},
@@ -375,7 +378,7 @@ def test_configuration_schema_with_partial_config(
     )
 
     assert response.status_code == 200
-    mock_cls.get_configuration_schema.assert_called_once_with({"account": "test"})
+    mock_cls.get_configuration_schema.assert_called_once_with(mock_config_obj)
 
 
 @SEMANTIC_LAYERS_APP
@@ -385,8 +388,19 @@ def test_configuration_schema_with_invalid_partial_config(
     mocker: MockerFixture,
 ) -> None:
     """Test /schema/configuration returns schema when partial config fails."""
+    from pydantic import ValidationError as PydanticValidationError
+
     mock_cls = MagicMock()
-    mock_cls.from_configuration.side_effect = ValueError("bad config")
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "type": "object",
+        "properties": {},
+    }
+    mock_cls.configuration_class.model_validate.side_effect = (
+        PydanticValidationError.from_exception_data(
+            title="test",
+            line_errors=[],
+        )
+    )
     mock_cls.get_configuration_schema.return_value = {"type": "object"}
 
     mocker.patch.dict(
@@ -832,6 +846,8 @@ def test_get_list_semantic_layers(
     layer1.description = "First"
     layer1.type = "snowflake"
     layer1.cache_timeout = None
+    layer1.configuration = "{}"
+    layer1.changed_on_delta_humanized.return_value = "1 day ago"
 
     layer2 = MagicMock()
     layer2.uuid = uuid_lib.uuid4()
@@ -839,6 +855,8 @@ def test_get_list_semantic_layers(
     layer2.description = None
     layer2.type = "snowflake"
     layer2.cache_timeout = 300
+    layer2.configuration = '{"account": "test"}'
+    layer2.changed_on_delta_humanized.return_value = "2 hours ago"
 
     mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
     mock_dao.find_all.return_value = [layer1, layer2]
@@ -851,6 +869,7 @@ def test_get_list_semantic_layers(
     assert result[0]["name"] == "Layer 1"
     assert result[1]["name"] == "Layer 2"
     assert result[1]["cache_timeout"] == 300
+    assert result[1]["configuration"] == {"account": "test"}
 
 
 @SEMANTIC_LAYERS_APP
@@ -883,6 +902,8 @@ def test_get_semantic_layer(
     layer.description = "A layer"
     layer.type = "snowflake"
     layer.cache_timeout = 600
+    layer.configuration = '{"account": "test"}'
+    layer.changed_on_delta_humanized.return_value = "1 day ago"
 
     mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
     mock_dao.find_by_uuid.return_value = layer
@@ -895,6 +916,7 @@ def test_get_semantic_layer(
     assert result["name"] == "My Layer"
     assert result["type"] == "snowflake"
     assert result["cache_timeout"] == 600
+    assert result["configuration"] == {"account": "test"}
 
 
 @SEMANTIC_LAYERS_APP
@@ -910,3 +932,559 @@ def test_get_semantic_layer_not_found(
     response = client.get(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
 
     assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_serialize_layer_string_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test _serialize_layer handles string configuration (JSON)."""
+    layer = MagicMock()
+    layer.uuid = uuid_lib.uuid4()
+    layer.name = "Layer"
+    layer.description = None
+    layer.type = "snowflake"
+    layer.cache_timeout = None
+    layer.configuration = '{"account": "test"}'
+    layer.changed_on_delta_humanized.return_value = "1 day ago"
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = layer
+
+    response = client.get(f"/api/v1/semantic_layer/{layer.uuid}")
+
+    assert response.status_code == 200
+    assert response.json["result"]["configuration"] == {"account": "test"}
+
+
+@SEMANTIC_LAYERS_APP
+def test_serialize_layer_dict_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test _serialize_layer handles dict configuration."""
+    layer = MagicMock()
+    layer.uuid = uuid_lib.uuid4()
+    layer.name = "Layer"
+    layer.description = None
+    layer.type = "snowflake"
+    layer.cache_timeout = None
+    layer.configuration = {"account": "test"}
+    layer.changed_on_delta_humanized.return_value = "1 day ago"
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = layer
+
+    response = client.get(f"/api/v1/semantic_layer/{layer.uuid}")
+
+    assert response.status_code == 200
+    assert response.json["result"]["configuration"] == {"account": "test"}
+
+
+@SEMANTIC_LAYERS_APP
+def test_serialize_layer_none_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test _serialize_layer handles None configuration."""
+    layer = MagicMock()
+    layer.uuid = uuid_lib.uuid4()
+    layer.name = "Layer"
+    layer.description = None
+    layer.type = "snowflake"
+    layer.cache_timeout = None
+    layer.configuration = None
+    layer.changed_on_delta_humanized.return_value = "1 day ago"
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = layer
+
+    response = client.get(f"/api/v1/semantic_layer/{layer.uuid}")
+
+    assert response.status_code == 200
+    assert response.json["result"]["configuration"] == {}
+
+
+def test_infer_discriminators_injects_discriminator() -> None:
+    """Test _infer_discriminators injects discriminator values."""
+    from superset.semantic_layers.api import _infer_discriminators
+
+    schema = {
+        "$defs": {
+            "VariantA": {"required": ["disc", "field_a"]},
+        },
+        "properties": {
+            "auth": {
+                "discriminator": {
+                    "propertyName": "disc",
+                    "mapping": {"a": "#/$defs/VariantA"},
+                },
+            },
+        },
+    }
+    data = {"auth": {"field_a": "value"}}
+    result = _infer_discriminators(schema, data)
+    assert result["auth"]["disc"] == "a"
+
+
+def test_infer_discriminators_no_match() -> None:
+    """Test _infer_discriminators returns data unchanged when no match."""
+    from superset.semantic_layers.api import _infer_discriminators
+
+    schema = {
+        "$defs": {
+            "VariantA": {"required": ["disc", "field_a"]},
+        },
+        "properties": {
+            "auth": {
+                "discriminator": {
+                    "propertyName": "disc",
+                    "mapping": {"a": "#/$defs/VariantA"},
+                },
+            },
+        },
+    }
+    data = {"auth": {"other": "value"}}
+    result = _infer_discriminators(schema, data)
+    assert "disc" not in result["auth"]
+
+
+def test_infer_discriminators_skips_non_dict() -> None:
+    """Test _infer_discriminators skips non-dict values."""
+    from superset.semantic_layers.api import _infer_discriminators
+
+    schema = {
+        "$defs": {},
+        "properties": {"auth": {"discriminator": {"propertyName": "disc"}}},
+    }
+    data = {"auth": "a string"}
+    result = _infer_discriminators(schema, data)
+    assert result == data
+
+
+def test_infer_discriminators_skips_if_discriminator_present() -> None:
+    """Test _infer_discriminators skips when discriminator already set."""
+    from superset.semantic_layers.api import _infer_discriminators
+
+    schema = {
+        "$defs": {},
+        "properties": {
+            "auth": {
+                "discriminator": {
+                    "propertyName": "disc",
+                    "mapping": {"a": "#/$defs/VariantA"},
+                },
+            },
+        },
+    }
+    data = {"auth": {"disc": "a", "field_a": "value"}}
+    result = _infer_discriminators(schema, data)
+    assert result["auth"]["disc"] == "a"
+
+
+def test_infer_discriminators_no_discriminator() -> None:
+    """Test _infer_discriminators skips properties without discriminator."""
+    from superset.semantic_layers.api import _infer_discriminators
+
+    schema = {
+        "$defs": {},
+        "properties": {"auth": {"type": "object"}},
+    }
+    data = {"auth": {"key": "val"}}
+    result = _infer_discriminators(schema, data)
+    assert result == data
+
+
+def test_parse_partial_config_strict_success() -> None:
+    """Test _parse_partial_config returns config on strict validation."""
+    from superset.semantic_layers.api import _parse_partial_config
+
+    mock_cls = MagicMock()
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "properties": {},
+    }
+    validated = MagicMock()
+    mock_cls.configuration_class.model_validate.return_value = validated
+
+    result = _parse_partial_config(mock_cls, {"key": "val"})
+    assert result == validated
+
+
+def test_parse_partial_config_falls_back_to_partial() -> None:
+    """Test _parse_partial_config falls back to partial validation."""
+    from pydantic import ValidationError as PydanticValidationError
+
+    from superset.semantic_layers.api import _parse_partial_config
+
+    mock_cls = MagicMock()
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "properties": {},
+    }
+    partial_result = MagicMock()
+    mock_cls.configuration_class.model_validate.side_effect = [
+        PydanticValidationError.from_exception_data(title="test", line_errors=[]),
+        partial_result,
+    ]
+
+    result = _parse_partial_config(mock_cls, {"key": "val"})
+    assert result == partial_result
+
+
+def test_parse_partial_config_returns_none_on_failure() -> None:
+    """Test _parse_partial_config returns None when all validation fails."""
+    from pydantic import ValidationError as PydanticValidationError
+
+    from superset.semantic_layers.api import _parse_partial_config
+
+    mock_cls = MagicMock()
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "properties": {},
+    }
+    err = PydanticValidationError.from_exception_data(title="test", line_errors=[])
+    mock_cls.configuration_class.model_validate.side_effect = err
+
+    result = _parse_partial_config(mock_cls, {"key": "val"})
+    assert result is None
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_enrichment_error_fallback(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test configuration_schema falls back when enrichment raises."""
+    mock_cls = MagicMock()
+    mock_cls.configuration_class.model_json_schema.return_value = {
+        "properties": {},
+    }
+    mock_cls.configuration_class.model_validate.return_value = MagicMock()
+    mock_cls.get_configuration_schema.side_effect = [
+        RuntimeError("connection failed"),
+        {"type": "object"},
+    ]
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "snowflake", "configuration": {"account": "test"}},
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"] == {"type": "object"}
+    assert mock_cls.get_configuration_schema.call_count == 2
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_list(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /connections/ returns combined database and layer list."""
+    from datetime import datetime
+
+    mock_db = MagicMock()
+    mock_db.id = 1
+    mock_db.uuid = uuid_lib.uuid4()
+    mock_db.database_name = "PostgreSQL"
+    mock_db.backend = "postgresql"
+    mock_db.allow_run_async = False
+    mock_db.allow_dml = False
+    mock_db.allow_file_upload = False
+    mock_db.expose_in_sqllab = True
+    mock_db.changed_on = datetime(2026, 1, 1)
+    mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+    mock_db.changed_by = None
+
+    mock_layer = MagicMock()
+    mock_layer.uuid = uuid_lib.uuid4()
+    mock_layer.name = "My Layer"
+    mock_layer.type = "snowflake"
+    mock_layer.description = "A layer"
+    mock_layer.cache_timeout = None
+    mock_layer.changed_on = datetime(2026, 2, 1)
+    mock_layer.changed_on_delta_humanized.return_value = "1 day ago"
+    mock_layer.changed_by = None
+
+    mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+    db_query = MagicMock()
+    db_query.all.return_value = [mock_db]
+    db_query.filter.return_value = db_query
+    sl_query = MagicMock()
+    sl_query.all.return_value = [mock_layer]
+    sl_query.filter.return_value = sl_query
+    mock_db_session.query.side_effect = [db_query, sl_query]
+
+    mock_cls = MagicMock()
+    mock_cls.name = "Snowflake"
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    mocker.patch(
+        "superset.semantic_layers.api.is_feature_enabled",
+        return_value=True,
+    )
+
+    response = client.get("/api/v1/semantic_layer/connections/")
+
+    assert response.status_code == 200
+    assert response.json["count"] == 2
+    result = response.json["result"]
+    assert len(result) == 2
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_database_only(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /connections/ with semantic layers disabled."""
+    from datetime import datetime
+
+    mock_db = MagicMock()
+    mock_db.id = 1
+    mock_db.uuid = uuid_lib.uuid4()
+    mock_db.database_name = "PostgreSQL"
+    mock_db.backend = "postgresql"
+    mock_db.allow_run_async = False
+    mock_db.allow_dml = False
+    mock_db.allow_file_upload = False
+    mock_db.expose_in_sqllab = True
+    mock_db.changed_on = datetime(2026, 1, 1)
+    mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+    mock_db.changed_by = MagicMock()
+    mock_db.changed_by.first_name = "John"
+    mock_db.changed_by.last_name = "Doe"
+
+    mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+    db_query = MagicMock()
+    db_query.all.return_value = [mock_db]
+    mock_db_session.query.return_value = db_query
+
+    mocker.patch(
+        "superset.semantic_layers.api.is_feature_enabled",
+        return_value=False,
+    )
+
+    response = client.get("/api/v1/semantic_layer/connections/")
+
+    assert response.status_code == 200
+    assert response.json["count"] == 1
+    result = response.json["result"][0]
+    assert result["source_type"] == "database"
+    assert result["database_name"] == "PostgreSQL"
+    assert result["changed_by"]["first_name"] == "John"
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_name_filter(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /connections/ with name filter."""
+    mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+    db_query = MagicMock()
+    db_query.all.return_value = []
+    db_query.filter.return_value = db_query
+    sl_query = MagicMock()
+    sl_query.all.return_value = []
+    sl_query.filter.return_value = sl_query
+    mock_db_session.query.side_effect = [db_query, sl_query]
+
+    mocker.patch(
+        "superset.semantic_layers.api.is_feature_enabled",
+        return_value=True,
+    )
+
+    import prison as rison_lib
+
+    q = rison_lib.dumps(
+        {"filters": [{"col": "database_name", "opr": "ct", "value": "post"}]}
+    )
+    response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+    assert response.status_code == 200
+    assert response.json["count"] == 0
+    # Verify filter was applied to both queries
+    db_query.filter.assert_called_once()
+    sl_query.filter.assert_called_once()
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_sort_by_name(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /connections/ sorts by database_name."""
+    from datetime import datetime
+
+    mock_db = MagicMock()
+    mock_db.id = 1
+    mock_db.uuid = uuid_lib.uuid4()
+    mock_db.database_name = "Zebra DB"
+    mock_db.backend = "postgresql"
+    mock_db.allow_run_async = False
+    mock_db.allow_dml = False
+    mock_db.allow_file_upload = False
+    mock_db.expose_in_sqllab = True
+    mock_db.changed_on = datetime(2026, 1, 1)
+    mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+    mock_db.changed_by = None
+
+    mock_layer = MagicMock()
+    mock_layer.uuid = uuid_lib.uuid4()
+    mock_layer.name = "Alpha Layer"
+    mock_layer.type = "snowflake"
+    mock_layer.description = None
+    mock_layer.cache_timeout = None
+    mock_layer.changed_on = datetime(2026, 2, 1)
+    mock_layer.changed_on_delta_humanized.return_value = "1 day ago"
+    mock_layer.changed_by = None
+
+    mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+    db_query = MagicMock()
+    db_query.all.return_value = [mock_db]
+    sl_query = MagicMock()
+    sl_query.all.return_value = [mock_layer]
+    mock_db_session.query.side_effect = [db_query, sl_query]
+
+    mock_cls = MagicMock()
+    mock_cls.name = "Snowflake"
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    mocker.patch(
+        "superset.semantic_layers.api.is_feature_enabled",
+        return_value=True,
+    )
+
+    import prison as rison_lib
+
+    q = rison_lib.dumps({"order_column": "database_name", "order_direction": "asc"})
+    response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert result[0]["database_name"] == "Alpha Layer"
+    assert result[1]["database_name"] == "Zebra DB"
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_source_type_filter(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /connections/ with source_type filter."""
+    from datetime import datetime
+
+    mock_db = MagicMock()
+    mock_db.id = 1
+    mock_db.uuid = uuid_lib.uuid4()
+    mock_db.database_name = "PostgreSQL"
+    mock_db.backend = "postgresql"
+    mock_db.allow_run_async = False
+    mock_db.allow_dml = False
+    mock_db.allow_file_upload = False
+    mock_db.expose_in_sqllab = True
+    mock_db.changed_on = datetime(2026, 1, 1)
+    mock_db.changed_on_delta_humanized.return_value = "1 month ago"
+    mock_db.changed_by = None
+
+    mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+    db_query = MagicMock()
+    db_query.all.return_value = [mock_db]
+    mock_db_session.query.return_value = db_query
+
+    mocker.patch(
+        "superset.semantic_layers.api.is_feature_enabled",
+        return_value=True,
+    )
+
+    import prison as rison_lib
+
+    q = rison_lib.dumps(
+        {"filters": [{"col": "source_type", "opr": "eq", "value": "database"}]}
+    )
+    response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+    assert response.status_code == 200
+    assert response.json["count"] == 1
+    # Only one query call (for Database), not two
+    mock_db_session.query.assert_called_once()
+
+
+@SEMANTIC_LAYERS_APP
+def test_connections_source_type_semantic_layer_only(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /connections/ with source_type=semantic_layer filter."""
+    from datetime import datetime
+
+    mock_layer = MagicMock()
+    mock_layer.uuid = uuid_lib.uuid4()
+    mock_layer.name = "My Layer"
+    mock_layer.type = "snowflake"
+    mock_layer.description = None
+    mock_layer.cache_timeout = None
+    mock_layer.changed_on = datetime(2026, 1, 1)
+    mock_layer.changed_on_delta_humanized.return_value = "1 day ago"
+    mock_layer.changed_by = None
+
+    mock_db_session = mocker.patch("superset.semantic_layers.api.db.session")
+    sl_query = MagicMock()
+    sl_query.all.return_value = [mock_layer]
+    mock_db_session.query.return_value = sl_query
+
+    mock_cls = MagicMock()
+    mock_cls.name = "Snowflake"
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    mocker.patch(
+        "superset.semantic_layers.api.is_feature_enabled",
+        return_value=True,
+    )
+
+    import prison as rison_lib
+
+    q = rison_lib.dumps(
+        {
+            "filters": [
+                {"col": "source_type", "opr": "eq", "value": "semantic_layer"},
+                {"col": "other_col", "opr": "eq", "value": "ignored"},
+            ]
+        }
+    )
+    response = client.get(f"/api/v1/semantic_layer/connections/?q={q}")
+
+    assert response.status_code == 200
+    assert response.json["count"] == 1
+    result = response.json["result"][0]
+    assert result["source_type"] == "semantic_layer"
+    # Only one query (SemanticLayer), no Database query
+    mock_db_session.query.assert_called_once()


(superset) 22/25: Fix filters

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 0e249e6348bc6ddf8685f0f3dfd194bf0bd10497
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 17:02:41 2026 -0400

    Fix filters
---
 .../src/superset_core/semantic_layers/view.py      |   7 +
 .../FilterBar/FilterControls/GroupByFilterCard.tsx |   5 +-
 .../features/semanticLayers/jsonFormsHelpers.tsx   |  27 +--
 superset-frontend/src/pages/DatasetList/index.tsx  | 202 +++++++++++----------
 4 files changed, 138 insertions(+), 103 deletions(-)

diff --git a/superset-core/src/superset_core/semantic_layers/view.py b/superset-core/src/superset_core/semantic_layers/view.py
index 7b4ef7faa2e..02bcdb820c8 100644
--- a/superset-core/src/superset_core/semantic_layers/view.py
+++ b/superset-core/src/superset_core/semantic_layers/view.py
@@ -47,6 +47,13 @@ class SemanticView(ABC):
 
     features: frozenset[SemanticViewFeature]
 
+    @property
+    @abstractmethod
+    def name(self) -> str:
+        """
+        Returns the display name of the semantic view.
+        """
+
     @abstractmethod
     def uid(self) -> str:
         """
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
index c6ce6629f82..ff6cf057cbc 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
@@ -51,7 +51,10 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
 import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
 import { dispatchChartCustomizationHoverAction } from './utils';
 import { mergeExtraFormData } from '../../utils';
-import { datasetLabel as getDatasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
+import {
+  datasetLabel as getDatasetLabel,
+  datasetLabelLower,
+} from 'src/utils/semanticLayerLabels';
 
 interface ColumnApiResponse {
   column_name?: string;
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
index 6fbdb8ea283..a4c65a1859c 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -105,15 +105,21 @@ function ReadOnlyControl({
     }
   }, [defaultValue, data, handleChange, path]);
 
-  return TextControl({ ...rest, data, handleChange, path, schema, enabled: false });
+  return TextControl({
+    ...rest,
+    data,
+    handleChange,
+    path,
+    schema,
+    enabled: false,
+  });
 }
 const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl);
 const readOnlyEntry = {
   tester: rankWith(
     11,
     schemaMatches(
-      s =>
-        s !== undefined && (s as Record<string, unknown>).readOnly === true,
+      s => s !== undefined && (s as Record<string, unknown>).readOnly === true,
     ),
   ),
   renderer: ReadOnlyRenderer,
@@ -147,7 +153,10 @@ function DynamicFieldControl(props: ControlProps) {
   const refreshing =
     refreshingSchema &&
     Array.isArray(deps) &&
-    areDependenciesSatisfied(deps as string[], (cfgData as Record<string, unknown>) ?? {});
+    areDependenciesSatisfied(
+      deps as string[],
+      (cfgData as Record<string, unknown>) ?? {},
+    );
 
   if (!refreshing) {
     return TextControl(props);
@@ -214,15 +223,13 @@ export function sanitizeSchema(schema: JsonSchema): JsonSchema {
  * Builds a JSON Forms UI schema from a JSON Schema, using the first
  * `examples` entry as placeholder text for each string property.
  */
-export function buildUiSchema(
-  schema: JsonSchema,
-): UISchemaElement | undefined {
+export function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined {
   if (!schema.properties) return undefined;
 
   // Use explicit property order from backend if available,
   // otherwise fall back to the JSON object key order
   const propertyOrder: string[] =
-    (schema as Record<string, unknown>)['x-propertyOrder'] as string[] ??
+    ((schema as Record<string, unknown>)['x-propertyOrder'] as string[]) ??
     Object.keys(schema.properties);
 
   const elements = propertyOrder
@@ -271,9 +278,7 @@ export function getDynamicDependencies(
       'x-dependsOn' in prop &&
       Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
     ) {
-      deps[key] = (prop as Record<string, unknown>)[
-        'x-dependsOn'
-      ] as string[];
+      deps[key] = (prop as Record<string, unknown>)['x-dependsOn'] as string[];
     }
   }
   return deps;
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index b9bb136cfcb..439cf481886 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -24,7 +24,14 @@ import {
   FeatureFlag,
 } from '@superset-ui/core';
 import { styled, useTheme, css } from '@apache-superset/core/theme';
-import { FunctionComponent, useState, useMemo, useCallback, useRef, Key } from 'react';
+import {
+  FunctionComponent,
+  useState,
+  useMemo,
+  useCallback,
+  useRef,
+  Key,
+} from 'react';
 import type { CellProps } from 'react-table';
 import { Link, useHistory } from 'react-router-dom';
 import rison from 'rison';
@@ -231,10 +238,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
    * - Selecting Source=Semantic Layer → clear DB connection + Physical/Virtual type
    */
   const cascadeClear = useCallback(
-    (
-      changed: 'source' | 'type' | 'connection',
-      newValue: unknown,
-    ) => {
+    (changed: 'source' | 'type' | 'connection', newValue: unknown) => {
       if (!isFeatureEnabled(FeatureFlag.SemanticLayers)) return;
 
       const isSlConnection = (v: unknown) =>
@@ -242,23 +246,34 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
       const isDbConnection = (v: unknown) =>
         v !== undefined && v !== null && v !== '' && !isSlConnection(v);
       const isSemanticViewType = (v: unknown) => v === 'semantic_view';
-      const isPhysicalVirtualType = (v: unknown) =>
-        v === true || v === false;
+      const isPhysicalVirtualType = (v: unknown) => v === true || v === false;
 
       if (changed === 'connection') {
-        if (isSlConnection(newValue) && isPhysicalVirtualType(currentTypeFilter.current)) {
+        if (
+          isSlConnection(newValue) &&
+          isPhysicalVirtualType(currentTypeFilter.current)
+        ) {
           filtersRef.current?.clearFilterById('sql');
         }
-        if (isDbConnection(newValue) && isSemanticViewType(currentTypeFilter.current)) {
+        if (
+          isDbConnection(newValue) &&
+          isSemanticViewType(currentTypeFilter.current)
+        ) {
           filtersRef.current?.clearFilterById('sql');
         }
       }
 
       if (changed === 'type') {
-        if (isSemanticViewType(newValue) && isDbConnection(currentConnectionFilter.current)) {
+        if (
+          isSemanticViewType(newValue) &&
+          isDbConnection(currentConnectionFilter.current)
+        ) {
           filtersRef.current?.clearFilterById('database');
         }
-        if (isPhysicalVirtualType(newValue) && isSlConnection(currentConnectionFilter.current)) {
+        if (
+          isPhysicalVirtualType(newValue) &&
+          isSlConnection(currentConnectionFilter.current)
+        ) {
           filtersRef.current?.clearFilterById('database');
         }
       }
@@ -318,7 +333,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           ? SupersetClient.get({
               endpoint: `/api/v1/semantic_layer/?q=${rison.encode_uri({
                 ...(filterValue
-                  ? { filters: [{ col: 'name', opr: 'ct', value: filterValue }] }
+                  ? {
+                      filters: [{ col: 'name', opr: 'ct', value: filterValue }],
+                    }
                   : {}),
                 page: 0,
                 page_size: 100,
@@ -347,90 +364,93 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     [currentSourceFilter],
   );
 
-  const fetchData = useCallback((config: ListViewFetchDataConfig) => {
-    setLastFetchConfig(config);
-    setLoading(true);
-    const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
-
-    // Separate source_type and database/connection filters for special handling
-    const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
-    const databaseFilter = filterValues.find(f => f.id === 'database');
-
-    const otherFilters = filterValues
-      .filter(f => f.id !== 'source_type' && f.id !== 'database')
-      .filter(
-        ({ value }) => value !== '' && value !== null && value !== undefined,
-      )
-      .map(({ id, operator: opr, value }) => ({
-        col: id,
-        opr,
-        value:
-          value && typeof value === 'object' && 'value' in value
-            ? value.value
-            : value,
-      }));
-
-      // Add source_type filter for the combined endpoint
-      const sourceTypeValue =
-        sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object'
-          ? (sourceTypeFilter.value as { value: string }).value
-          : (sourceTypeFilter?.value as string | undefined);
-      if (sourceTypeValue) {
-        otherFilters.push({
-          col: 'source_type',
-          opr: 'eq',
-          value: sourceTypeValue,
-        });
-      }
+  const fetchData = useCallback(
+    (config: ListViewFetchDataConfig) => {
+      setLastFetchConfig(config);
+      setLoading(true);
+      const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
+
+      // Separate source_type and database/connection filters for special handling
+      const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
+      const databaseFilter = filterValues.find(f => f.id === 'database');
+
+      const otherFilters = filterValues
+        .filter(f => f.id !== 'source_type' && f.id !== 'database')
+        .filter(
+          ({ value }) => value !== '' && value !== null && value !== undefined,
+        )
+        .map(({ id, operator: opr, value }) => ({
+          col: id,
+          opr,
+          value:
+            value && typeof value === 'object' && 'value' in value
+              ? value.value
+              : value,
+        }));
+
+        // Add source_type filter for the combined endpoint
+        const sourceTypeValue =
+          sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object'
+            ? (sourceTypeFilter.value as { value: string }).value
+            : (sourceTypeFilter?.value as string | undefined);
+        if (sourceTypeValue) {
+          otherFilters.push({
+            col: 'source_type',
+            opr: 'eq',
+            value: sourceTypeValue,
+          });
+        }
 
-    const queryParams = rison.encode_uri({
-      order_column: sortBy[0].id,
-      order_direction: sortBy[0].desc ? 'desc' : 'asc',
-      page: pageIndex,
-      page_size: pageSize,
-      ...(otherFilters.length ? { filters: otherFilters } : {}),
-    });
+      const queryParams = rison.encode_uri({
+        order_column: sortBy[0].id,
+        order_direction: sortBy[0].desc ? 'desc' : 'asc',
+        page: pageIndex,
+        page_size: pageSize,
+        ...(otherFilters.length ? { filters: otherFilters } : {}),
+      });
 
-    // Translate the "Data connection" filter: values prefixed with "sl:" are
-    // semantic layer UUIDs; plain values are database IDs.
-    if (databaseFilter?.value !== undefined && databaseFilter.value !== '') {
-      const raw =
-        databaseFilter.value &&
-        typeof databaseFilter.value === 'object' &&
-        'value' in databaseFilter.value
-          ? (databaseFilter.value as { value: unknown }).value
-          : databaseFilter.value;
-      if (typeof raw === 'string' && raw.startsWith('sl:')) {
-        otherFilters.push({
-          col: 'semantic_layer_uuid',
-          opr: 'eq',
-          value: raw.slice(3),
-        });
-      } else if (raw !== null && raw !== undefined && raw !== '') {
-        otherFilters.push({
-          col: 'database',
-          opr: databaseFilter.operator,
-          value: raw as string | number,
-        });
+      // Translate the "Data connection" filter: values prefixed with "sl:" are
+      // semantic layer UUIDs; plain values are database IDs.
+      if (databaseFilter?.value !== undefined && databaseFilter.value !== '') {
+        const raw =
+          databaseFilter.value &&
+          typeof databaseFilter.value === 'object' &&
+          'value' in databaseFilter.value
+            ? (databaseFilter.value as { value: unknown }).value
+            : databaseFilter.value;
+        if (typeof raw === 'string' && raw.startsWith('sl:')) {
+          otherFilters.push({
+            col: 'semantic_layer_uuid',
+            opr: 'eq',
+            value: raw.slice(3),
+          });
+        } else if (raw !== null && raw !== undefined && raw !== '') {
+          otherFilters.push({
+            col: 'database',
+            opr: databaseFilter.operator,
+            value: raw as string | number,
+          });
+        }
       }
-    }
 
-    return SupersetClient.get({
-      endpoint: `/api/v1/datasource/?q=${queryParams}`,
-    })
-      .then(({ json = {} }) => {
-        setDatasets(json.result);
-        setDatasetCount(json.count);
+      return SupersetClient.get({
+        endpoint: `/api/v1/datasource/?q=${queryParams}`,
       })
-      .catch(() => {
-        addDangerToast(
-          t('An error occurred while fetching %s', datasetsLabelLower()),
-        );
-      })
-      .finally(() => {
-        setLoading(false);
-      });
-  }, [addDangerToast]);
+        .then(({ json = {} }) => {
+          setDatasets(json.result);
+          setDatasetCount(json.count);
+        })
+        .catch(() => {
+          addDangerToast(
+            t('An error occurred while fetching %s', datasetsLabelLower()),
+          );
+        })
+        .finally(() => {
+          setLoading(false);
+        });
+    },
+    [addDangerToast],
+  );
 
   const refreshData = useCallback(() => {
     if (lastFetchConfig) {


(superset) 15/25: Revert some aggressive renames

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit bf71d49bd69f4d0ea49addc6ecc9104bf92363f5
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Mar 12 12:05:22 2026 -0400

    Revert some aggressive renames
---
 superset-frontend/src/components/Chart/Chart.tsx           |  3 +--
 .../src/components/Datasource/DatasourceModal/index.tsx    |  7 +++----
 .../components/DatasourceEditor/DatasourceEditor.tsx       |  3 +--
 superset-frontend/src/components/ImportModal/index.tsx     |  3 +--
 .../src/explore/components/DatasourcePanel/index.tsx       |  3 +--
 .../src/explore/components/ExploreChartPanel/index.tsx     |  5 ++---
 superset-frontend/src/explore/components/SaveModal.tsx     |  8 +++-----
 .../DndColumnSelectControl/ColumnSelectPopover.tsx         |  7 +++----
 .../MetricControl/AdhocMetricEditPopover/index.tsx         |  3 +--
 .../explore/components/controls/ViewQueryModalFooter.tsx   |  3 +--
 superset-frontend/src/features/home/RightMenu.tsx          | 14 +++++---------
 superset-frontend/src/pages/ChartCreation/index.tsx        |  6 ++----
 12 files changed, 24 insertions(+), 41 deletions(-)

diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx
index c20ca163c95..a7e38aa61a4 100644
--- a/superset-frontend/src/components/Chart/Chart.tsx
+++ b/superset-frontend/src/components/Chart/Chart.tsx
@@ -46,7 +46,6 @@ import { Dispatch } from 'redux';
 import ChartRenderer from './ChartRenderer';
 import { ChartErrorMessage } from './ChartErrorMessage';
 import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage';
-import { databaseLabelLower } from 'src/utils/semanticLayerLabels';
 
 export type ChartErrorType = Partial<ClientErrorObject>;
 export interface ChartProps {
@@ -313,7 +312,7 @@ class Chart extends PureComponent<ChartProps, {}> {
   renderSpinner(databaseName: string | undefined) {
     const message = databaseName
       ? t('Waiting on %s', databaseName)
-      : t('Waiting on %s...', databaseLabelLower());
+      : t('Waiting on database...');
 
     return (
       <LoadingDiv>
diff --git a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
index ef4033331fc..d3e34b76484 100644
--- a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
+++ b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
@@ -40,7 +40,6 @@ import withToasts from 'src/components/MessageToasts/withToasts';
 import { ErrorMessageWithStackTrace } from 'src/components';
 import type { DatasetObject } from 'src/features/datasets/types';
 import type { DatasourceModalProps } from '../types';
-import { datasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const DatasourceEditor = AsyncEsmComponent(
   () => import('../components/DatasourceEditor'),
@@ -196,7 +195,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
         endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
       });
 
-      addSuccessToast(t('The %s has been saved', datasetLabelLower()));
+      addSuccessToast(t('The dataset has been saved'));
       // eslint-disable-next-line no-param-reassign
       json.result.type = 'table';
       onDatasourceSave({
@@ -217,7 +216,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
         errorText = error.error;
       }
       modal.error({
-        title: t('Error saving %s', datasetLabelLower()),
+        title: t('Error saving dataset'),
         okButtonProps: { danger: true, className: 'btn-danger' },
         content: (
           <ErrorMessageWithStackTrace
@@ -327,7 +326,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
             `}
             data-test="edit-alt"
           />
-          {t('Edit %s ', datasetLabel())}
+          {t('Edit Dataset ')}
           <strong>{currentDatasource.table_name}</strong>
         </span>
       }
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index ba8710bf2d2..6150927ca6a 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -101,7 +101,6 @@ import {
 } from '../../FoldersEditor/treeUtils';
 import FoldersEditor from '../../FoldersEditor';
 import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types';
-import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 const extensionsRegistry = getExtensionsRegistry();
 
@@ -1907,7 +1906,7 @@ class DatasourceEditor extends PureComponent<
                             onChange={table => {
                               this.onDatasourcePropChange('table_name', table);
                             }}
-                            placeholder={t('%s name', datasetLabel())}
+                            placeholder={t('Dataset name')}
                             disabled={!this.state.isEditMode}
                           />
                         }
diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx
index a3097803b14..b046abf8880 100644
--- a/superset-frontend/src/components/ImportModal/index.tsx
+++ b/superset-frontend/src/components/ImportModal/index.tsx
@@ -29,7 +29,6 @@ import { Button, Input, Modal } from '@superset-ui/core/components';
 import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
 import { ImportErrorAlert } from './ImportErrorAlert';
 import type { ImportModelsModalProps } from './types';
-import { databaseLabel } from 'src/utils/semanticLayerLabels';
 
 const HelperMessage = styled.div`
   display: block;
@@ -248,7 +247,7 @@ export const ImportModal: FunctionComponent<ImportModelsModalProps> = ({
 
     return (
       <>
-        <h5>{t('%s passwords', databaseLabel())}</h5>
+        <h5>{t('Database passwords')}</h5>
         <HelperMessage>{passwordsNeededMessage}</HelperMessage>
         {files.map(fileName => (
           <>
diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
index 8a340c55f25..74718ae3751 100644
--- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
+++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
@@ -22,7 +22,6 @@ import { DatasourceType, Metric, QueryFormData } from '@superset-ui/core';
 import { Alert } from '@apache-superset/core/components';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
 
-import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 import { ControlConfig } from '@superset-ui/chart-controls';
 import AutoSizer from 'react-virtualized-auto-sizer';
 
@@ -292,7 +291,7 @@ export default function DataSourcePanel({
                       onClick={() => setShowSaveDatasetModal(true)}
                       className="add-dataset-alert-description"
                     >
-                      {t('Create a %s', datasetLabelLower())}
+                      {t('Create a dataset')}
                     </span>
                     {t(' to edit or add columns and metrics.')}
                   </>
diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
index fbfce00da15..c692e65bfd1 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
@@ -31,7 +31,6 @@ import {
   getExtensionsRegistry,
 } from '@superset-ui/core';
 import { Alert } from '@apache-superset/core/components';
-import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
 import ChartContainer from 'src/components/Chart/ChartContainer';
 import {
@@ -326,7 +325,7 @@ const ExploreChartPanel = ({
       >
         {vizTypeNeedsDataset && (
           <Alert
-            message={t('Chart type requires a %s', datasetLabelLower())}
+            message={t('Chart type requires a dataset')}
             type="error"
             css={theme => css`
               margin: 0 0 ${theme.sizeUnit * 4}px 0;
@@ -342,7 +341,7 @@ const ExploreChartPanel = ({
                   onClick={() => setShowDatasetModal(true)}
                   css={{ textDecoration: 'underline' }}
                 >
-                  {t('Create a %s', datasetLabelLower())}
+                  {t('Create a dataset')}
                 </span>
                 {t(' to visualize your data.')}
               </>
diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx
index 2d05d1817e0..a78b4ce7dc1 100644
--- a/superset-frontend/src/explore/components/SaveModal.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.tsx
@@ -59,7 +59,6 @@ import {
 import { Dashboard } from 'src/types/Dashboard';
 import { TabNode, TabTreeNode } from '../types';
 import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants';
-import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 // Session storage key for recent dashboard
 const SK_DASHBOARD_ID = 'save_chart_recent_dashboard';
@@ -636,12 +635,11 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
           <FormItem
             label={
               <Flex align="center" gap={this.props.theme.sizeUnit}>
-                {t('%s Name', datasetLabel())}
+                {t('Dataset Name')}
                 <InfoTooltip
                   data-test="info-tooltip-icon"
                   tooltip={t(
-                    'A reusable %s will be saved with your chart.',
-                    datasetLabel().toLowerCase(),
+                    'A reusable dataset will be saved with your chart.',
                   )}
                   placement="right"
                 />
@@ -652,7 +650,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
             <Input
               name="dataset_name"
               type="text"
-              placeholder={t('%s Name', datasetLabel())}
+              placeholder={t('Dataset Name')}
               value={this.state.datasetName}
               onChange={this.handleDatasetNameChange}
               data-test="new-dataset-name"
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx
index bd8ab88c700..f61fafe8bcb 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx
@@ -57,7 +57,6 @@ import {
 } from 'src/explore/constants';
 import { ExplorePageState } from 'src/explore/types';
 import useResizeButton from './useResizeButton';
-import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const TABS_KEYS = {
   SAVED: 'saved',
@@ -473,7 +472,7 @@ const ColumnSelectPopover = ({
                                   tabIndex={0}
                                   onClick={setDatasetAndClose}
                                 >
-                                  {t('Create a %s', datasetLabelLower())}
+                                  {t('Create a dataset')}
                                 </span>{' '}
                                 {t(' to mark a column as a time column')}
                               </>
@@ -484,7 +483,7 @@ const ColumnSelectPopover = ({
                                   tabIndex={0}
                                   onClick={setDatasetAndClose}
                                 >
-                                  {t('Create a %s', datasetLabelLower())}
+                                  {t('Create a dataset')}
                                 </span>{' '}
                                 {t(' to add calculated columns')}
                               </>
@@ -518,7 +517,7 @@ const ColumnSelectPopover = ({
                             tabIndex={0}
                             onClick={setDatasetAndClose}
                           >
-                            {t('Create a %s', datasetLabelLower())}
+                            {t('Create a dataset')}
                           </span>{' '}
                           {t(' to mark a column as a time column')}
                         </>
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
index 000ac461e9c..031248df0a0 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
@@ -49,7 +49,6 @@ import {
 import { getColumnKeywords } from 'src/explore/controlUtils/getColumnKeywords';
 import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
 import type { RefObject } from 'react';
-import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 interface ColumnType {
   column_name: string;
@@ -476,7 +475,7 @@ export default class AdhocMetricEditPopover extends PureComponent<
                             this.props.onClose();
                           }}
                         >
-                          {t('Create a %s', datasetLabelLower())}
+                          {t('Create a dataset')}
                         </span>
                         {t(' to add metrics')}
                       </>
diff --git a/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx b/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx
index ee14b70436f..fc8c7c948b2 100644
--- a/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx
+++ b/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx
@@ -22,7 +22,6 @@ import { t } from '@apache-superset/core/translation';
 import { SupersetClient } from '@superset-ui/core';
 import { Button } from '@superset-ui/core/components';
 import { useHistory } from 'react-router-dom';
-import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 interface SimpleDataSource {
   id: string;
@@ -37,7 +36,7 @@ interface ViewQueryModalFooterProps {
 }
 
 const CLOSE = t('Close');
-const SAVE_AS_DATASET = t('Save as %s', datasetLabel());
+const SAVE_AS_DATASET = t('Save as Dataset');
 const OPEN_IN_SQL_LAB = t('Open in SQL Lab');
 
 const ViewQueryModalFooter: FC<ViewQueryModalFooterProps> = (props: {
diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx
index ea35f46db16..28c25498642 100644
--- a/superset-frontend/src/features/home/RightMenu.tsx
+++ b/superset-frontend/src/features/home/RightMenu.tsx
@@ -66,10 +66,6 @@ import {
   RightMenuProps,
 } from './types';
 import { NAVBAR_MENU_POPUP_OFFSET } from './commonMenuData';
-import {
-  datasetLabelLower,
-  databaseLabelLower,
-} from 'src/utils/semanticLayerLabels';
 
 const extensionsRegistry = getExtensionsRegistry();
 
@@ -180,12 +176,12 @@ const RightMenu = ({
       icon: <Icons.DatabaseOutlined data-test={`menu-item-${t('Data')}`} />,
       childs: [
         {
-          label: t('Connect %s', databaseLabelLower()),
+          label: t('Connect database'),
           name: GlobalMenuDataOptions.DbConnection,
           perm: canDatabase && !nonExamplesDBConnected,
         },
         {
-          label: t('Create %s', datasetLabelLower()),
+          label: t('Create dataset'),
           name: GlobalMenuDataOptions.DatasetCreation,
           url: '/dataset/add/',
           perm: canDataset && nonExamplesDBConnected,
@@ -196,19 +192,19 @@ const RightMenu = ({
           perm: canDatabase && HAS_GSHEETS_INSTALLED,
         },
         {
-          label: t('Upload CSV to %s', databaseLabelLower()),
+          label: t('Upload CSV to database'),
           name: GlobalMenuDataOptions.CSVUpload,
           perm: canUploadCSV && showUploads,
           disable: isAdmin && !allowUploads,
         },
         {
-          label: t('Upload Excel to %s', databaseLabelLower()),
+          label: t('Upload Excel to database'),
           name: GlobalMenuDataOptions.ExcelUpload,
           perm: canUploadExcel && showUploads,
           disable: isAdmin && !allowUploads,
         },
         {
-          label: t('Upload Columnar file to %s', databaseLabelLower()),
+          label: t('Upload Columnar file to database'),
           name: GlobalMenuDataOptions.ColumnarUpload,
           perm: canUploadColumnar && showUploads,
           disable: isAdmin && !allowUploads,
diff --git a/superset-frontend/src/pages/ChartCreation/index.tsx b/superset-frontend/src/pages/ChartCreation/index.tsx
index 05a7b8f3486..ab8b102098b 100644
--- a/superset-frontend/src/pages/ChartCreation/index.tsx
+++ b/superset-frontend/src/pages/ChartCreation/index.tsx
@@ -211,9 +211,7 @@ export class ChartCreation extends PureComponent<
         .catch(() => {
           this.setState({ loading: false });
         });
-      this.props.addSuccessToast(
-        t('The %s has been saved', datasetLabelLower()),
-      );
+      this.props.addSuccessToast(t('The dataset has been saved'));
     }
   }
 
@@ -298,7 +296,7 @@ export class ChartCreation extends PureComponent<
     const datasetHelpText = this.state.canCreateDataset ? (
       <span data-test="dataset-write">
         <Link to="/dataset/add/" data-test="add-chart-new-dataset">
-          {t('Add a %s', datasetLabelLower())}
+          {t('Add a dataset')}
         </Link>{' '}
         {t('or')}{' '}
         <a


(superset) 08/25: Fix lint/tests

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 4490bffd500a4275408b042634f28e1790add233
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Mar 11 15:03:43 2026 -0400

    Fix lint/tests
---
 .../features/semanticLayers/jsonFormsHelpers.tsx   |  34 +-
 .../semanticViews/AddSemanticViewModal.tsx         |  98 +++---
 superset-frontend/src/pages/DatasetList/index.tsx  |  32 +-
 tests/unit_tests/semantic_layers/api_test.py       | 388 +++++++++++++++++++++
 4 files changed, 470 insertions(+), 82 deletions(-)

diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
index ced9992f427..bc5c5f54cca 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -76,6 +76,23 @@ const constEntry = {
   renderer: ConstRenderer,
 };
 
+/**
+ * Checks whether all dependency values are filled (non-empty).
+ * Handles nested objects (like auth) by checking they have at least one key.
+ */
+export function areDependenciesSatisfied(
+  dependencies: string[],
+  data: Record<string, unknown>,
+): boolean {
+  return dependencies.every(dep => {
+    const value = data[dep];
+    if (value === null || value === undefined || value === '') return false;
+    if (typeof value === 'object' && Object.keys(value).length === 0)
+      return false;
+    return true;
+  });
+}
+
 /**
  * Renderer for fields marked `x-dynamic` in the JSON Schema.
  * Shows a loading spinner inside the input while the schema is being
@@ -218,23 +235,6 @@ export function getDynamicDependencies(
   return deps;
 }
 
-/**
- * Checks whether all dependency values are filled (non-empty).
- * Handles nested objects (like auth) by checking they have at least one key.
- */
-export function areDependenciesSatisfied(
-  dependencies: string[],
-  data: Record<string, unknown>,
-): boolean {
-  return dependencies.every(dep => {
-    const value = data[dep];
-    if (value === null || value === undefined || value === '') return false;
-    if (typeof value === 'object' && Object.keys(value).length === 0)
-      return false;
-    return true;
-  });
-}
-
 /**
  * Serializes the dependency values for a set of fields into a stable string
  * for comparison, so we only re-fetch when dependency values actually change.
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
index 1658a4cac35..f7ee1b110c2 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -129,31 +129,6 @@ export default function AddSemanticViewModal({
   const [selectedViews, setSelectedViews] = useState<Set<string>>(new Set());
   const [loadingViews, setLoadingViews] = useState(false);
 
-  // Reset state when modal closes
-  useEffect(() => {
-    if (show) {
-      fetchLayers();
-    } else {
-      setStep('layer');
-      setLayers([]);
-      setSelectedLayerUuid(null);
-      setLoading(false);
-      setSaving(false);
-      setRuntimeSchema(null);
-      setRuntimeUiSchema(undefined);
-      setRuntimeData({});
-      setRefreshingSchema(false);
-      setHasRuntimeErrors(false);
-      errorsRef.current = [];
-      lastDepSnapshotRef.current = '';
-      dynamicDepsRef.current = {};
-      setAvailableViews([]);
-      setSelectedViews(new Set());
-      setLoadingViews(false);
-      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
-    }
-  }, [show]); // eslint-disable-line react-hooks/exhaustive-deps
-
   const fetchLayers = async () => {
     setLoading(true);
     try {
@@ -173,6 +148,30 @@ export default function AddSemanticViewModal({
     }
   };
 
+  const fetchViews = useCallback(
+    async (uuid: string, rData: Record<string, unknown>) => {
+      setLoadingViews(true);
+      setStep('select');
+      try {
+        const { json } = await SupersetClient.post({
+          endpoint: `/api/v1/semantic_layer/${uuid}/views`,
+          jsonPayload: { runtime_data: rData },
+        });
+        const views: AvailableView[] = json.result ?? [];
+        setAvailableViews(views);
+        // Pre-select views that are already added (disabled anyway)
+        setSelectedViews(
+          new Set(views.filter(v => v.already_added).map(v => v.name)),
+        );
+      } catch {
+        addDangerToast(t('An error occurred while fetching available views'));
+      } finally {
+        setLoadingViews(false);
+      }
+    },
+    [addDangerToast],
+  );
+
   const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
     const schema = sanitizeSchema(rawSchema);
     setRuntimeSchema(schema);
@@ -218,32 +217,33 @@ export default function AddSemanticViewModal({
         else setRefreshingSchema(false);
       }
     },
-    [addDangerToast, applyRuntimeSchema], // eslint-disable-line react-hooks/exhaustive-deps
+    [addDangerToast, applyRuntimeSchema, fetchViews],
   );
 
-  const fetchViews = useCallback(
-    async (uuid: string, rData: Record<string, unknown>) => {
-      setLoadingViews(true);
-      setStep('select');
-      try {
-        const { json } = await SupersetClient.post({
-          endpoint: `/api/v1/semantic_layer/${uuid}/views`,
-          jsonPayload: { runtime_data: rData },
-        });
-        const views: AvailableView[] = json.result ?? [];
-        setAvailableViews(views);
-        // Pre-select views that are already added (disabled anyway)
-        setSelectedViews(
-          new Set(views.filter(v => v.already_added).map(v => v.name)),
-        );
-      } catch {
-        addDangerToast(t('An error occurred while fetching available views'));
-      } finally {
-        setLoadingViews(false);
-      }
-    },
-    [addDangerToast],
-  );
+  // Reset state when modal closes
+  useEffect(() => {
+    if (show) {
+      fetchLayers();
+    } else {
+      setStep('layer');
+      setLayers([]);
+      setSelectedLayerUuid(null);
+      setLoading(false);
+      setSaving(false);
+      setRuntimeSchema(null);
+      setRuntimeUiSchema(undefined);
+      setRuntimeData({});
+      setRefreshingSchema(false);
+      setHasRuntimeErrors(false);
+      errorsRef.current = [];
+      lastDepSnapshotRef.current = '';
+      dynamicDepsRef.current = {};
+      setAvailableViews([]);
+      setSelectedViews(new Set());
+      setLoadingViews(false);
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    }
+  }, [show]); // eslint-disable-line react-hooks/exhaustive-deps
 
   const maybeRefreshRuntimeSchema = useCallback(
     (data: Record<string, unknown>) => {
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 107e8e936fe..49b2bdc4676 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -407,6 +407,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     [addDangerToast, setPreparingExport],
   );
 
+  const handleSemanticViewDelete = ({ id, table_name: tableName }: Dataset) => {
+    SupersetClient.delete({
+      endpoint: `/api/v1/semantic_view/${id}`,
+    }).then(
+      () => {
+        refreshData();
+        addSuccessToast(t('Deleted: %s', tableName));
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting %s: %s', tableName, errMsg),
+        ),
+      ),
+    );
+  };
+
   const columns = useMemo(
     () => [
       {
@@ -989,22 +1005,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     );
   };
 
-  const handleSemanticViewDelete = ({ id, table_name: tableName }: Dataset) => {
-    SupersetClient.delete({
-      endpoint: `/api/v1/semantic_view/${id}`,
-    }).then(
-      () => {
-        refreshData();
-        addSuccessToast(t('Deleted: %s', tableName));
-      },
-      createErrorHandler(errMsg =>
-        addDangerToast(
-          t('There was an issue deleting %s: %s', tableName, errMsg),
-        ),
-      ),
-    );
-  };
-
   const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
     const datasets = datasetsToDelete.filter(
       d => d.source_type !== 'semantic_layer',
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index c6482c0f139..c65cc6a7780 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -28,6 +28,8 @@ from superset.commands.semantic_layer.exceptions import (
     SemanticLayerInvalidError,
     SemanticLayerNotFoundError,
     SemanticLayerUpdateFailedError,
+    SemanticViewCreateFailedError,
+    SemanticViewDeleteFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -1488,3 +1490,389 @@ def test_connections_source_type_semantic_layer_only(
     assert result["source_type"] == "semantic_layer"
     # Only one query (SemanticLayer), no Database query
     mock_db_session.query.assert_called_once()
+
+
+# =============================================================================
+# SemanticViewRestApi.post (bulk create) tests
+# =============================================================================
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_view_bulk_create(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / bulk creates semantic views."""
+    new_model = MagicMock()
+    new_model.uuid = uuid_lib.uuid4()
+    new_model.name = "View 1"
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+    mock_command.return_value.run.return_value = new_model
+
+    payload = {
+        "views": [
+            {
+                "name": "View 1",
+                "semantic_layer_uuid": str(uuid_lib.uuid4()),
+                "configuration": {"database": "db1"},
+            },
+        ],
+    }
+    response = client.post("/api/v1/semantic_view/", json=payload)
+
+    assert response.status_code == 201
+    result = response.json["result"]
+    assert len(result["created"]) == 1
+    assert result["created"][0]["name"] == "View 1"
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_view_empty_views(
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """Test POST / returns 400 when no views provided."""
+    response = client.post("/api/v1/semantic_view/", json={"views": []})
+
+    assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_view_validation_error(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / collects validation errors for individual views."""
+    # Missing required field "semantic_layer_uuid"
+    payload = {
+        "views": [
+            {"name": "Bad View"},
+        ],
+    }
+    response = client.post("/api/v1/semantic_view/", json=payload)
+
+    assert response.status_code == 422
+    result = response.json["result"]
+    assert len(result["errors"]) == 1
+    assert result["errors"][0]["name"] == "Bad View"
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_view_layer_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / collects layer-not-found errors."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerNotFoundError()
+
+    payload = {
+        "views": [
+            {
+                "name": "View 1",
+                "semantic_layer_uuid": str(uuid_lib.uuid4()),
+                "configuration": {},
+            },
+        ],
+    }
+    response = client.post("/api/v1/semantic_view/", json=payload)
+
+    assert response.status_code == 422
+    result = response.json["result"]
+    assert len(result["errors"]) == 1
+    assert result["errors"][0]["error"] == "Semantic layer not found"
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_view_create_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / collects create-failed errors."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticViewCreateFailedError()
+
+    payload = {
+        "views": [
+            {
+                "name": "View 1",
+                "semantic_layer_uuid": str(uuid_lib.uuid4()),
+                "configuration": {},
+            },
+        ],
+    }
+    response = client.post("/api/v1/semantic_view/", json=payload)
+
+    assert response.status_code == 422
+    result = response.json["result"]
+    assert len(result["errors"]) == 1
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_view_partial_success(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / returns 201 with partial success (some created, some errors)."""
+    new_model = MagicMock()
+    new_model.uuid = uuid_lib.uuid4()
+    new_model.name = "Good View"
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = [
+        new_model,
+        SemanticLayerNotFoundError(),
+    ]
+
+    layer_uuid = str(uuid_lib.uuid4())
+    payload = {
+        "views": [
+            {
+                "name": "Good View",
+                "semantic_layer_uuid": layer_uuid,
+                "configuration": {},
+            },
+            {
+                "name": "Bad View",
+                "semantic_layer_uuid": layer_uuid,
+                "configuration": {},
+            },
+        ],
+    }
+    response = client.post("/api/v1/semantic_view/", json=payload)
+
+    assert response.status_code == 201
+    result = response.json["result"]
+    assert len(result["created"]) == 1
+    assert len(result["errors"]) == 1
+
+
+# =============================================================================
+# SemanticViewRestApi.delete tests
+# =============================================================================
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_view(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<pk> deletes a semantic view."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticViewCommand",
+    )
+    mock_command.return_value.run.return_value = None
+
+    response = client.delete("/api/v1/semantic_view/1")
+
+    assert response.status_code == 200
+    mock_command.assert_called_once_with("1")
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_view_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<pk> returns 404 when view not found."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticViewNotFoundError()
+
+    response = client.delete("/api/v1/semantic_view/999")
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_view_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<pk> returns 422 when deletion fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticViewDeleteFailedError()
+
+    response = client.delete("/api/v1/semantic_view/1")
+
+    assert response.status_code == 422
+
+
+# =============================================================================
+# SemanticLayerRestApi.views tests
+# =============================================================================
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_views(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/views returns available views."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.uuid = uuid_lib.uuid4()
+
+    mock_view1 = MagicMock()
+    mock_view1.name = "View A"
+    mock_view2 = MagicMock()
+    mock_view2.name = "View B"
+    mock_layer.implementation.get_semantic_views.return_value = [
+        mock_view1,
+        mock_view2,
+    ]
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
+    mock_sv_dao.find_by_semantic_layer.return_value = []
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/views",
+        json={"runtime_data": {"database": "mydb"}},
+    )
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert len(result) == 2
+    assert result[0]["name"] == "View A"
+    assert result[0]["already_added"] is False
+    assert result[1]["name"] == "View B"
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_views_with_existing(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/views marks already-added views."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.uuid = uuid_lib.uuid4()
+
+    mock_view = MagicMock()
+    mock_view.name = "Existing View"
+    mock_layer.implementation.get_semantic_views.return_value = [mock_view]
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    existing_view = MagicMock()
+    existing_view.name = "Existing View"
+    existing_view.configuration = '{"database": "mydb"}'
+
+    mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
+    mock_sv_dao.find_by_semantic_layer.return_value = [existing_view]
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/views",
+        json={"runtime_data": {"database": "mydb"}},
+    )
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert len(result) == 1
+    assert result[0]["name"] == "Existing View"
+    assert result[0]["already_added"] is True
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_views_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/views returns 404 when layer not found."""
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = None
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}/views",
+        json={},
+    )
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_views_exception(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/views returns 400 when implementation raises."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.uuid = uuid_lib.uuid4()
+    mock_layer.implementation.get_semantic_views.side_effect = ValueError(
+        "Connection failed"
+    )
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/views",
+        json={"runtime_data": {}},
+    )
+
+    assert response.status_code == 400
+    assert "Connection failed" in response.json["message"]
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_views_existing_dict_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/views handles dict configuration on existing views."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.uuid = uuid_lib.uuid4()
+
+    mock_view = MagicMock()
+    mock_view.name = "View X"
+    mock_layer.implementation.get_semantic_views.return_value = [mock_view]
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    existing_view = MagicMock()
+    existing_view.name = "View X"
+    existing_view.configuration = {"key": "val"}  # dict, not string
+
+    mock_sv_dao = mocker.patch("superset.semantic_layers.api.SemanticViewDAO")
+    mock_sv_dao.find_by_semantic_layer.return_value = [existing_view]
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/views",
+        json={"runtime_data": {"key": "val"}},
+    )
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert result[0]["already_added"] is True


(superset) 10/25: Address comments

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1f43b3fa0ee39d9b41772eba0c5a60d0d7fee29f
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Mon Mar 30 20:05:07 2026 -0400

    Address comments
---
 superset-frontend/src/pages/DatasetList/index.tsx | 24 ++++++++++++++++++++++-
 superset/semantic_layers/api.py                   | 13 +++++++++++-
 2 files changed, 35 insertions(+), 2 deletions(-)

diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 49b2bdc4676..8302e165e2f 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -270,6 +270,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     null,
   );
 
+  const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
+    useState<Dataset | null>(null);
+
   const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
     useState(false);
   const [importingDataset, showImportModal] = useState<boolean>(false);
@@ -407,11 +410,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     [addDangerToast, setPreparingExport],
   );
 
-  const handleSemanticViewDelete = ({ id, table_name: tableName }: Dataset) => {
+  const handleSemanticViewDelete = (sv: Dataset) => {
+    setSvCurrentlyDeleting(sv);
+  };
+
+  const handleSemanticViewDeleteConfirm = () => {
+    if (!svCurrentlyDeleting) return;
+    const { id, table_name: tableName } = svCurrentlyDeleting;
     SupersetClient.delete({
       endpoint: `/api/v1/semantic_view/${id}`,
     }).then(
       () => {
+        setSvCurrentlyDeleting(null);
         refreshData();
         addSuccessToast(t('Deleted: %s', tableName));
       },
@@ -1189,6 +1199,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           title={t('Delete Dataset?')}
         />
       )}
+      {svCurrentlyDeleting && (
+        <DeleteModal
+          description={t(
+            'Are you sure you want to delete %s?',
+            svCurrentlyDeleting.table_name,
+          )}
+          onConfirm={handleSemanticViewDeleteConfirm}
+          onHide={() => setSvCurrentlyDeleting(null)}
+          open
+          title={t('Delete Semantic View?')}
+        />
+      )}
       {datasetCurrentlyEditing && (
         <DatasourceModal
           datasource={datasetCurrentlyEditing}
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 20526c9e42b..d24b87e7c04 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -23,6 +23,7 @@ from flask import make_response, request, Response
 from flask_appbuilder.api import expose, protect, rison, safe
 from flask_appbuilder.api.schemas import get_list_schema
 from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_babel import lazy_gettext as t
 from marshmallow import ValidationError
 from pydantic import ValidationError as PydanticValidationError
 
@@ -545,7 +546,17 @@ class SemanticLayerRestApi(BaseSupersetApi):
         try:
             views = layer.implementation.get_semantic_views(runtime_data)
         except Exception as ex:  # pylint: disable=broad-except
-            return self.response_400(message=str(ex))
+            logger.error(
+                "Error fetching semantic views for layer %s: %s",
+                uuid,
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_400(
+                message=t(
+                    "Unable to fetch semantic views. Check the layer configuration."
+                )
+            )
 
         # Check which views already exist with the same runtime config
         existing = SemanticViewDAO.find_by_semantic_layer(str(layer.uuid))


(superset) 16/25: Fix lint

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1d4f31c4dfb3b4c69eda766e8cefe7a1bfc94297
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Mar 12 17:20:56 2026 -0400

    Fix lint
---
 .../nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx      | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
index 2c609088340..c6ce6629f82 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
@@ -51,7 +51,7 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
 import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
 import { dispatchChartCustomizationHoverAction } from './utils';
 import { mergeExtraFormData } from '../../utils';
-import { datasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
+import { datasetLabel as getDatasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 interface ColumnApiResponse {
   column_name?: string;
@@ -263,7 +263,7 @@ const GroupByFilterCardContent: FC<{
       </Row>
 
       <Row>
-        <RowLabel>{datasetLabel()}</RowLabel>
+        <RowLabel>{getDatasetLabel()}</RowLabel>
         <RowValue>
           {typeof datasetLabel === 'string' ? datasetLabel : 'Dataset'}
         </RowValue>


(superset) 12/25: Small fixes

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 3c20d62ed249ef5757637376f8a7d4fa4fdf994f
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Mon Apr 20 15:55:34 2026 -0400

    Small fixes
---
 superset-frontend/src/pages/DatasetList/index.tsx  | 19 +++++----
 superset/commands/semantic_layer/delete.py         | 12 ++++++
 superset/semantic_layers/api.py                    |  4 ++
 .../commands/semantic_layer/delete_test.py         | 49 ++++++++++++++++++++++
 tests/unit_tests/semantic_layers/api_test.py       |  2 +-
 5 files changed, 76 insertions(+), 10 deletions(-)

diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 4d194ffd260..dcb9f4d11cd 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -1045,17 +1045,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
       );
     }
 
-    Promise.all(promises).then(
-      () => {
-        refreshData();
+    Promise.allSettled(promises).then(results => {
+      const failures = results.filter(r => r.status === 'rejected');
+      // Always refresh so the list reflects whatever actually got deleted.
+      refreshData();
+      if (failures.length === 0) {
         addSuccessToast(t('Deleted %s item(s)', datasetsToDelete.length));
-      },
-      createErrorHandler(errMsg =>
+      } else {
         addDangerToast(
-          t('There was an issue deleting the selected datasets: %s', errMsg),
-        ),
-      ),
-    );
+          t('There was an issue deleting the selected items'),
+        );
+      }
+    });
   };
 
   const handleDatasetDuplicate = (newDatasetName: string) => {
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index bdc13502f4d..0093be42514 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -21,14 +21,17 @@ from functools import partial
 
 from sqlalchemy.exc import SQLAlchemyError
 
+from superset import security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
     SemanticLayerDeleteFailedError,
     SemanticLayerNotFoundError,
     SemanticViewDeleteFailedError,
+    SemanticViewForbiddenError,
     SemanticViewNotFoundError,
 )
 from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
+from superset.exceptions import SupersetSecurityException
 from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.utils.decorators import on_error, transaction
 
@@ -79,6 +82,10 @@ class DeleteSemanticViewCommand(BaseCommand):
         self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
         if not self._model:
             raise SemanticViewNotFoundError()
+        try:
+            security_manager.raise_for_ownership(self._model)
+        except SupersetSecurityException as ex:
+            raise SemanticViewForbiddenError() from ex
 
 
 class BulkDeleteSemanticViewCommand(BaseCommand):
@@ -101,3 +108,8 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
         self._models = SemanticViewDAO.find_by_ids(self._model_ids, id_column="id")
         if len(self._models) != len(self._model_ids):
             raise SemanticViewNotFoundError()
+        for model in self._models:
+            try:
+                security_manager.raise_for_ownership(model)
+            except SupersetSecurityException as ex:
+                raise SemanticViewForbiddenError() from ex
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index b1935065b27..b5d5bdc6bf2 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -362,6 +362,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
             return self.response(200, message="OK")
         except SemanticViewNotFoundError:
             return self.response_404()
+        except SemanticViewForbiddenError:
+            return self.response_403()
         except SemanticViewDeleteFailedError as ex:
             logger.error(
                 "Error deleting semantic view: %s",
@@ -422,6 +424,8 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
             )
         except SemanticViewNotFoundError:
             return self.response_404()
+        except SemanticViewForbiddenError:
+            return self.response_403()
         except SemanticViewDeleteFailedError as ex:
             logger.error(
                 "Error bulk deleting semantic views: %s",
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 36f39895eb1..e5b6e5e8549 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -59,6 +59,11 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
     )
     dao.find_by_id.return_value = mock_model
 
+    # Admin is owner of everything — no exception raised
+    mocker.patch(
+        "superset.commands.semantic_layer.delete.security_manager"
+    ).raise_for_ownership.return_value = None
+
     from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
 
     DeleteSemanticViewCommand(42).run()
@@ -67,6 +72,26 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
     dao.delete.assert_called_once_with([mock_model])
 
 
+def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
+    """Test that SemanticViewForbiddenError is raised for non-owners."""
+    from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
+    from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
+    from superset.exceptions import SupersetSecurityException
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticViewDAO",
+    )
+    dao.find_by_id.return_value = MagicMock()
+
+    mocker.patch(
+        "superset.security_manager.raise_for_ownership",
+        side_effect=SupersetSecurityException(MagicMock()),
+    )
+
+    with pytest.raises(SemanticViewForbiddenError):
+        DeleteSemanticViewCommand(42).run()
+
+
 def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
     """Test that SemanticViewNotFoundError is raised when view is missing."""
     dao = mocker.patch(
@@ -92,6 +117,10 @@ def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
     )
     dao.find_by_ids.return_value = mock_models
 
+    mocker.patch(
+        "superset.commands.semantic_layer.delete.security_manager"
+    ).raise_for_ownership.return_value = None
+
     from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
 
     BulkDeleteSemanticViewCommand([1, 2]).run()
@@ -100,6 +129,26 @@ def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
     dao.delete.assert_called_once_with(mock_models)
 
 
+def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
+    """Test that SemanticViewForbiddenError is raised for non-owners."""
+    from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
+    from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
+    from superset.exceptions import SupersetSecurityException
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticViewDAO",
+    )
+    dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
+
+    mocker.patch(
+        "superset.security_manager.raise_for_ownership",
+        side_effect=SupersetSecurityException(MagicMock()),
+    )
+
+    with pytest.raises(SemanticViewForbiddenError):
+        BulkDeleteSemanticViewCommand([1, 2]).run()
+
+
 def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
     """Test that SemanticViewNotFoundError is raised when any id is missing."""
     dao = mocker.patch(
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index 5dfcb6e25e6..f7acfb47e34 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -1907,7 +1907,7 @@ def test_get_views_exception(
     )
 
     assert response.status_code == 400
-    assert "Connection failed" in response.json["message"]
+    assert "Unable to fetch semantic views" in response.json["message"]
 
 
 @SEMANTIC_LAYERS_APP


(superset) 23/25: Fix lint

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit b2784f297cadf06ff2b7e3916f38ccc60ae2d161
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 18:54:28 2026 -0400

    Fix lint
---
 superset-frontend/src/components/ListView/ListView.tsx | 9 ++++++---
 superset/daos/semantic_layer.py                        | 5 +++--
 2 files changed, 9 insertions(+), 5 deletions(-)

diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx
index 6a3e60f852c..b0fdabea83c 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -19,7 +19,7 @@
 import { t } from '@apache-superset/core/translation';
 import { Alert } from '@apache-superset/core/components';
 import { styled } from '@apache-superset/core/theme';
-import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
+import { useCallback, useEffect, useLayoutEffect, useRef, useState, ReactNode } from 'react';
 import cx from 'classnames';
 import TableCollection from '@superset-ui/core/components/TableCollection';
 import BulkTagModal from 'src/features/tags/BulkTagModal';
@@ -349,9 +349,12 @@ export function ListView<T extends object = any>({
   }>(null);
 
   // Wire the optional external filtersRef to our internal filterControlsRef.
-  useEffect(() => {
+  // useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
+  // ref is populated before the first paint and after every update.
+  useLayoutEffect(() => {
     if (filtersRef) {
-      (filtersRef as any).current = filterControlsRef.current;
+      (filtersRef as React.MutableRefObject<typeof filterControlsRef.current>).current =
+        filterControlsRef.current;
     }
   });
 
diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py
index c92745bd119..424a4cdbc3d 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -34,12 +34,13 @@ from superset.utils import json
 
 
 class SemanticLayerDAO(BaseDAO[SemanticLayer], AbstractSemanticLayerDAO):
-    # SemanticLayer uses uuid as the primary key
-    id_column_name = "uuid"
     """
     Data Access Object for SemanticLayer model.
     """
 
+    # SemanticLayer uses uuid as the primary key
+    id_column_name = "uuid"
+
     model_cls = SemanticLayer
 
     @staticmethod


(superset) 11/25: Bulk delete

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit cc15ffd044913fd59ca2e5512419b223554bcec6
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Tue Mar 31 10:41:10 2026 -0400

    Bulk delete
---
 superset-frontend/src/pages/DatasetList/index.tsx  | 10 ++--
 superset/commands/semantic_layer/delete.py         | 22 +++++++
 superset/semantic_layers/api.py                    | 66 ++++++++++++++++++++-
 .../commands/semantic_layer/delete_test.py         | 32 +++++++++++
 tests/unit_tests/semantic_layers/api_test.py       | 67 ++++++++++++++++++++++
 5 files changed, 190 insertions(+), 7 deletions(-)

diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 8302e165e2f..4d194ffd260 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -1037,11 +1037,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
 
     if (semanticViews.length) {
       promises.push(
-        ...semanticViews.map(sv =>
-          SupersetClient.delete({
-            endpoint: `/api/v1/semantic_view/${sv.id}`,
-          }),
-        ),
+        SupersetClient.delete({
+          endpoint: `/api/v1/semantic_view/?q=${rison.encode(
+            semanticViews.map(({ id }) => id),
+          )}`,
+        }),
       );
     }
 
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index a69d35d0f5d..bdc13502f4d 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -79,3 +79,25 @@ class DeleteSemanticViewCommand(BaseCommand):
         self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
         if not self._model:
             raise SemanticViewNotFoundError()
+
+
+class BulkDeleteSemanticViewCommand(BaseCommand):
+    def __init__(self, model_ids: list[int]):
+        self._model_ids = model_ids
+        self._models: list[SemanticView] = []
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError,),
+            reraise=SemanticViewDeleteFailedError,
+        )
+    )
+    def run(self) -> None:
+        self.validate()
+        SemanticViewDAO.delete(self._models)
+
+    def validate(self) -> None:
+        self._models = SemanticViewDAO.find_by_ids(self._model_ids, id_column="id")
+        if len(self._models) != len(self._model_ids):
+            raise SemanticViewNotFoundError()
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index d24b87e7c04..b1935065b27 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -23,7 +23,7 @@ from flask import make_response, request, Response
 from flask_appbuilder.api import expose, protect, rison, safe
 from flask_appbuilder.api.schemas import get_list_schema
 from flask_appbuilder.models.sqla.interface import SQLAInterface
-from flask_babel import lazy_gettext as t
+from flask_babel import lazy_gettext as t, ngettext
 from marshmallow import ValidationError
 from pydantic import ValidationError as PydanticValidationError
 
@@ -33,6 +33,7 @@ from superset.commands.semantic_layer.create import (
     CreateSemanticViewCommand,
 )
 from superset.commands.semantic_layer.delete import (
+    BulkDeleteSemanticViewCommand,
     DeleteSemanticLayerCommand,
     DeleteSemanticViewCommand,
 )
@@ -55,6 +56,7 @@ from superset.commands.semantic_layer.update import (
 )
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
 from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
+from superset.datasets.schemas import get_delete_ids_schema
 from superset.models.core import Database
 from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.semantic_layers.registry import registry
@@ -171,7 +173,7 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
     allow_browser_login = True
     class_permission_name = "SemanticView"
     method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
-    include_route_methods = {"put", "post", "delete"}
+    include_route_methods = {"put", "post", "delete", "bulk_delete"}
 
     edit_model_schema = SemanticViewPutSchema()
 
@@ -368,6 +370,66 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
             )
             return self.response_422(message=str(ex))
 
+    @expose("/", methods=("DELETE",))
+    @protect()
+    @statsd_metrics
+    @rison(get_delete_ids_schema)
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
+        log_to_statsd=False,
+    )
+    def bulk_delete(self, **kwargs: Any) -> Response:
+        """Bulk delete semantic views.
+        ---
+        delete:
+          summary: Bulk delete semantic views
+          parameters:
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/get_delete_ids_schema'
+          responses:
+            200:
+              description: Semantic views deleted
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        item_ids: list[int] = kwargs["rison"]
+        try:
+            BulkDeleteSemanticViewCommand(item_ids).run()
+            return self.response(
+                200,
+                message=ngettext(
+                    "Deleted %(num)d semantic view",
+                    "Deleted %(num)d semantic views",
+                    num=len(item_ids),
+                ),
+            )
+        except SemanticViewNotFoundError:
+            return self.response_404()
+        except SemanticViewDeleteFailedError as ex:
+            logger.error(
+                "Error bulk deleting semantic views: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
 
 class SemanticLayerRestApi(BaseSupersetApi):
     resource_name = "semantic_layer"
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 288f22710fc..36f39895eb1 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -81,3 +81,35 @@ def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
 
     with pytest.raises(SemanticViewNotFoundError):
         DeleteSemanticViewCommand(999).run()
+
+
+def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
+    """Test successful bulk deletion of semantic views."""
+    mock_models = [MagicMock(), MagicMock()]
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticViewDAO",
+    )
+    dao.find_by_ids.return_value = mock_models
+
+    from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
+
+    BulkDeleteSemanticViewCommand([1, 2]).run()
+
+    dao.find_by_ids.assert_called_once_with([1, 2], id_column="id")
+    dao.delete.assert_called_once_with(mock_models)
+
+
+def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
+    """Test that SemanticViewNotFoundError is raised when any id is missing."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticViewDAO",
+    )
+    # Only one model returned for two requested ids
+    dao.find_by_ids.return_value = [MagicMock()]
+
+    from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
+    from superset.commands.semantic_layer.exceptions import SemanticViewNotFoundError
+
+    with pytest.raises(SemanticViewNotFoundError):
+        BulkDeleteSemanticViewCommand([1, 2]).run()
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index c65cc6a7780..5dfcb6e25e6 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -1718,6 +1718,73 @@ def test_delete_semantic_view_failed(
     assert response.status_code == 422
 
 
+# =============================================================================
+# SemanticViewRestApi.bulk_delete tests
+# =============================================================================
+
+
+@SEMANTIC_LAYERS_APP
+def test_bulk_delete_semantic_view(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE / deletes multiple semantic views and returns a count message."""
+    import prison as rison_lib
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
+    )
+    mock_command.return_value.run.return_value = None
+
+    q = rison_lib.dumps([1, 2, 3])
+    response = client.delete(f"/api/v1/semantic_view/?q={q}")
+
+    assert response.status_code == 200
+    assert "3" in response.json["message"]
+    mock_command.assert_called_once_with([1, 2, 3])
+
+
+@SEMANTIC_LAYERS_APP
+def test_bulk_delete_semantic_view_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE / returns 404 when any id is missing."""
+    import prison as rison_lib
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticViewNotFoundError()
+
+    q = rison_lib.dumps([1, 999])
+    response = client.delete(f"/api/v1/semantic_view/?q={q}")
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_bulk_delete_semantic_view_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE / returns 422 when deletion fails."""
+    import prison as rison_lib
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticViewDeleteFailedError()
+
+    q = rison_lib.dumps([1, 2])
+    response = client.delete(f"/api/v1/semantic_view/?q={q}")
+
+    assert response.status_code == 422
+
+
 # =============================================================================
 # SemanticLayerRestApi.views tests
 # =============================================================================


(superset) 05/25: Fix rebase

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 4c645ce952dd7a985176bdf792df1c25c14c714a
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Tue Mar 10 19:28:32 2026 -0400

    Fix rebase
---
 .../commands/semantic_layer/update_test.py         | 110 ---------------------
 1 file changed, 110 deletions(-)

diff --git a/tests/unit_tests/commands/semantic_layer/update_test.py b/tests/unit_tests/commands/semantic_layer/update_test.py
index 3bf85738edf..1b144428e89 100644
--- a/tests/unit_tests/commands/semantic_layer/update_test.py
+++ b/tests/unit_tests/commands/semantic_layer/update_test.py
@@ -221,116 +221,6 @@ def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
     assert original_data == {"description": "Original"}
 
 
-# =============================================================================
-# UpdateSemanticLayerCommand tests
-# =============================================================================
-
-
-def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
-    """Test successful update of a semantic layer."""
-    mock_model = MagicMock()
-    mock_model.type = "snowflake"
-
-    dao = mocker.patch(
-        "superset.commands.semantic_layer.update.SemanticLayerDAO",
-    )
-    dao.find_by_uuid.return_value = mock_model
-    dao.update.return_value = mock_model
-
-    data = {"name": "Updated", "description": "New desc"}
-    result = UpdateSemanticLayerCommand("some-uuid", data).run()
-
-    assert result == mock_model
-    dao.find_by_uuid.assert_called_once_with("some-uuid")
-    dao.update.assert_called_once_with(mock_model, attributes=data)
-
-
-def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
-    """Test that SemanticLayerNotFoundError is raised when model is missing."""
-    dao = mocker.patch(
-        "superset.commands.semantic_layer.update.SemanticLayerDAO",
-    )
-    dao.find_by_uuid.return_value = None
-
-    with pytest.raises(SemanticLayerNotFoundError):
-        UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
-
-
-def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
-    """Test that SemanticLayerInvalidError is raised for duplicate names."""
-    mock_model = MagicMock()
-    mock_model.type = "snowflake"
-
-    dao = mocker.patch(
-        "superset.commands.semantic_layer.update.SemanticLayerDAO",
-    )
-    dao.find_by_uuid.return_value = mock_model
-    dao.validate_update_uniqueness.return_value = False
-
-    with pytest.raises(SemanticLayerInvalidError):
-        UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
-
-
-def test_update_semantic_layer_validates_configuration(
-    mocker: MockerFixture,
-) -> None:
-    """Test that configuration is validated against the plugin."""
-    mock_model = MagicMock()
-    mock_model.type = "snowflake"
-
-    dao = mocker.patch(
-        "superset.commands.semantic_layer.update.SemanticLayerDAO",
-    )
-    dao.find_by_uuid.return_value = mock_model
-    dao.update.return_value = mock_model
-
-    mock_cls = MagicMock()
-    mocker.patch.dict(
-        "superset.commands.semantic_layer.update.registry",
-        {"snowflake": mock_cls},
-    )
-
-    config = {"account": "test"}
-    UpdateSemanticLayerCommand("some-uuid", {"configuration": config}).run()
-
-    mock_cls.from_configuration.assert_called_once_with(config)
-
-
-def test_update_semantic_layer_skips_name_check_when_no_name(
-    mocker: MockerFixture,
-) -> None:
-    """Test that name uniqueness is not checked when name is not provided."""
-    mock_model = MagicMock()
-    mock_model.type = "snowflake"
-
-    dao = mocker.patch(
-        "superset.commands.semantic_layer.update.SemanticLayerDAO",
-    )
-    dao.find_by_uuid.return_value = mock_model
-    dao.update.return_value = mock_model
-
-    UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
-
-    dao.validate_update_uniqueness.assert_not_called()
-
-
-def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
-    """Test that the command copies input data and does not mutate it."""
-    mock_model = MagicMock()
-    mock_model.type = "snowflake"
-
-    dao = mocker.patch(
-        "superset.commands.semantic_layer.update.SemanticLayerDAO",
-    )
-    dao.find_by_uuid.return_value = mock_model
-    dao.update.return_value = mock_model
-
-    original_data = {"description": "Original"}
-    UpdateSemanticLayerCommand("some-uuid", original_data).run()
-
-    assert original_data == {"description": "Original"}
-
-
 def _make_view_model(
     uuid: str = "view-uuid-1",
     name: str = "my_view",


(superset) 20/25: Working on filters

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 5b4035427dc89c35b9dde14f8b5815e405a4c345
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 15:10:13 2026 -0400

    Working on filters
---
 superset-frontend/src/pages/DatasetList/index.tsx | 219 ++++++++++++++--------
 superset/commands/datasource/list.py              |  63 +++++--
 superset/daos/datasource.py                       |  14 +-
 3 files changed, 210 insertions(+), 86 deletions(-)

diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 3d22d5b4a9b..b189e87759a 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -195,28 +195,103 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
   const [lastFetchConfig, setLastFetchConfig] =
     useState<ListViewFetchDataConfig | null>(null);
 
-  const fetchData = useCallback(
-    (config: ListViewFetchDataConfig) => {
-      setLastFetchConfig(config);
-      setLoading(true);
-      const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
-
-      // Separate source_type filter from other filters
-      const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
-
-      const otherFilters = filterValues
-        .filter(f => f.id !== 'source_type')
-        .filter(
-          ({ value }) => value !== '' && value !== null && value !== undefined,
-        )
-        .map(({ id, operator: opr, value }) => ({
-          col: id,
-          opr,
-          value:
-            value && typeof value === 'object' && 'value' in value
-              ? value.value
-              : value,
-        }));
+  const currentSourceFilter = useMemo(() => {
+    const sourceTypeFilter = lastFetchConfig?.filters.find(
+      filter => filter.id === 'source_type',
+    );
+    if (
+      sourceTypeFilter?.value &&
+      typeof sourceTypeFilter.value === 'object' &&
+      'value' in sourceTypeFilter.value
+    ) {
+      return sourceTypeFilter.value.value as string;
+    }
+    return (sourceTypeFilter?.value as string | undefined) ?? '';
+  }, [lastFetchConfig]);
+
+  /**
+   * Fetches "Data connection" filter options — a combined list of databases
+   * and semantic layers.
+   *
+   * Semantic layer values are prefixed with "sl:" so that fetchData can tell
+   * them apart from integer database IDs and route to the correct API filter.
+   */
+  const fetchConnectionOptions = useCallback(
+    async (filterValue = '', page: number, pageSize: number) => {
+      const showDatabases = currentSourceFilter !== 'semantic_layer';
+      const showSemanticLayers =
+        isFeatureEnabled(FeatureFlag.SemanticLayers) &&
+        currentSourceFilter !== 'database';
+
+      const [dbResult, slResult] = await Promise.all([
+        showDatabases
+          ? createFetchRelated(
+              'dataset',
+              'database',
+              createErrorHandler(errMsg =>
+                t(
+                  'An error occurred while fetching %s: %s',
+                  datasetsLabelLower(),
+                  errMsg,
+                ),
+              ),
+            )(filterValue, page, pageSize)
+          : Promise.resolve({ data: [], totalCount: 0 }),
+        showSemanticLayers
+          ? SupersetClient.get({
+              endpoint: `/api/v1/semantic_layer/?q=${rison.encode_uri({
+                ...(filterValue
+                  ? { filters: [{ col: 'name', opr: 'ct', value: filterValue }] }
+                  : {}),
+                page: 0,
+                page_size: 100,
+              })}`,
+            })
+              .then(({ json = {} }) => ({
+                data: (json?.result ?? []).map(
+                  (layer: { uuid: string; name: string }) => ({
+                    label: layer.name,
+                    // "sl:" prefix distinguishes semantic layers from DB integer IDs
+                    value: `sl:${layer.uuid}`,
+                  }),
+                ),
+                totalCount: json?.count ?? 0,
+              }))
+              .catch(() => ({ data: [], totalCount: 0 }))
+          : Promise.resolve({ data: [], totalCount: 0 }),
+      ]);
+
+      return {
+        // Semantic layers first, then databases
+        data: [...slResult.data, ...dbResult.data],
+        totalCount: slResult.totalCount + dbResult.totalCount,
+      };
+    },
+    [currentSourceFilter],
+  );
+
+  const fetchData = useCallback((config: ListViewFetchDataConfig) => {
+    setLastFetchConfig(config);
+    setLoading(true);
+    const { pageIndex, pageSize, sortBy, filters: filterValues } = config;
+
+    // Separate source_type and database/connection filters for special handling
+    const sourceTypeFilter = filterValues.find(f => f.id === 'source_type');
+    const databaseFilter = filterValues.find(f => f.id === 'database');
+
+    const otherFilters = filterValues
+      .filter(f => f.id !== 'source_type' && f.id !== 'database')
+      .filter(
+        ({ value }) => value !== '' && value !== null && value !== undefined,
+      )
+      .map(({ id, operator: opr, value }) => ({
+        col: id,
+        opr,
+        value:
+          value && typeof value === 'object' && 'value' in value
+            ? value.value
+            : value,
+      }));
 
       // Add source_type filter for the combined endpoint
       const sourceTypeValue =
@@ -231,32 +306,54 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         });
       }
 
-      const queryParams = rison.encode_uri({
-        order_column: sortBy[0].id,
-        order_direction: sortBy[0].desc ? 'desc' : 'asc',
-        page: pageIndex,
-        page_size: pageSize,
-        ...(otherFilters.length ? { filters: otherFilters } : {}),
-      });
+    const queryParams = rison.encode_uri({
+      order_column: sortBy[0].id,
+      order_direction: sortBy[0].desc ? 'desc' : 'asc',
+      page: pageIndex,
+      page_size: pageSize,
+      ...(otherFilters.length ? { filters: otherFilters } : {}),
+    });
 
-      return SupersetClient.get({
-        endpoint: `/api/v1/datasource/?q=${queryParams}`,
-      })
-        .then(({ json = {} }) => {
-          setDatasets(json.result);
-          setDatasetCount(json.count);
-        })
-        .catch(() => {
-          addDangerToast(
-            t('An error occurred while fetching %s', datasetsLabelLower()),
-          );
-        })
-        .finally(() => {
-          setLoading(false);
+    // Translate the "Data connection" filter: values prefixed with "sl:" are
+    // semantic layer UUIDs; plain values are database IDs.
+    if (databaseFilter?.value !== undefined && databaseFilter.value !== '') {
+      const raw =
+        databaseFilter.value &&
+        typeof databaseFilter.value === 'object' &&
+        'value' in databaseFilter.value
+          ? (databaseFilter.value as { value: unknown }).value
+          : databaseFilter.value;
+      if (typeof raw === 'string' && raw.startsWith('sl:')) {
+        otherFilters.push({
+          col: 'semantic_layer_uuid',
+          opr: 'eq',
+          value: raw.slice(3),
         });
-    },
-    [addDangerToast],
-  );
+      } else if (raw !== null && raw !== undefined && raw !== '') {
+        otherFilters.push({
+          col: 'database',
+          opr: databaseFilter.operator,
+          value: raw,
+        });
+      }
+    }
+
+    return SupersetClient.get({
+      endpoint: `/api/v1/datasource/?q=${queryParams}`,
+    })
+      .then(({ json = {} }) => {
+        setDatasets(json.result);
+        setDatasetCount(json.count);
+      })
+      .catch(() => {
+        addDangerToast(
+          t('An error occurred while fetching %s', datasetsLabelLower()),
+        );
+      })
+      .finally(() => {
+        setLoading(false);
+      });
+  }, [addDangerToast]);
 
   const refreshData = useCallback(() => {
     if (lastFetchConfig) {
@@ -310,20 +407,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
       state.common?.conf?.PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET || false,
   );
 
-  const currentSourceFilter = useMemo(() => {
-    const sourceTypeFilter = lastFetchConfig?.filters.find(
-      filter => filter.id === 'source_type',
-    );
-    if (
-      sourceTypeFilter?.value &&
-      typeof sourceTypeFilter.value === 'object' &&
-      'value' in sourceTypeFilter.value
-    ) {
-      return sourceTypeFilter.value.value as string;
-    }
-    return (sourceTypeFilter?.value as string | undefined) ?? '';
-  }, [lastFetchConfig]);
-
   const openDatasetImportModal = () => {
     showImportModal(true);
   };
@@ -773,7 +856,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
               operator: FilterOperator.Equals,
               unfilteredLabel: t('All'),
               selects: [
-                { label: databaseLabel(), value: 'database' },
+                { label: t('Database'), value: 'database' },
                 { label: t('Semantic Layer'), value: 'semantic_layer' },
               ],
             },
@@ -826,20 +909,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         Header: databaseLabel(),
         key: 'database',
         id: 'database',
-        input: 'select',
+        input: 'select' as const,
         operator: FilterOperator.RelationOneMany,
         unfilteredLabel: 'All',
-        fetchSelects: createFetchRelated(
-          'dataset',
-          'database',
-          createErrorHandler(errMsg =>
-            t(
-              'An error occurred while fetching %s: %s',
-              datasetsLabelLower(),
-              errMsg,
-            ),
-          ),
-        ),
+        fetchSelects: fetchConnectionOptions,
         paginate: true,
         dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
       },
diff --git a/superset/commands/datasource/list.py b/superset/commands/datasource/list.py
index 7966f0189bf..f02dd2da566 100644
--- a/superset/commands/datasource/list.py
+++ b/superset/commands/datasource/list.py
@@ -62,14 +62,32 @@ class GetCombinedDatasourceListCommand(BaseCommand):
         order_direction = self._args.get("order_direction", "desc")
         filters = self._args.get("filters", [])
 
-        source_type, name_filter, sql_filter, type_filter = self._parse_filters(filters)
+        (
+            source_type,
+            name_filter,
+            sql_filter,
+            type_filter,
+            database_id,
+            semantic_layer_uuid,
+        ) = self._parse_filters(filters)
+
+        # A connection filter implicitly narrows the source type: selecting a
+        # database ID means "show only datasets", and selecting a semantic layer
+        # UUID means "show only semantic views".  Only apply the implicit
+        # narrowing when the user hasn't already set an explicit source_type.
+        if source_type == "all":
+            if database_id is not None:
+                source_type = "database"
+            elif semantic_layer_uuid is not None:
+                source_type = "semantic_layer"
+
         source_type = self._resolve_source_type(source_type, sql_filter, type_filter)
 
         if source_type == "empty":
             return {"count": 0, "result": []}
 
-        ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter)
-        sv_q = DatasourceDAO.build_semantic_view_query(name_filter)
+        ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter, database_id)
+        sv_q = DatasourceDAO.build_semantic_view_query(name_filter, semantic_layer_uuid)
 
         if source_type == "database":
             combined = ds_q.subquery()
@@ -126,10 +144,15 @@ class GetCombinedDatasourceListCommand(BaseCommand):
             return "database"
         if not self._can_read_datasets:
             return "semantic_layer"
+        # An explicit source_type selection ("database" or "semantic_layer") always
+        # wins. This prevents e.g. Type="Semantic View" from overriding an explicit
+        # Source="Database" filter and showing inconsistent results.
+        if source_type in ("database", "semantic_layer"):
+            return source_type
         # sql_filter (physical/virtual toggle) only applies to datasets
         if sql_filter is not None:
             return "database"
-        # Explicit semantic-view type filter
+        # Explicit semantic-view type filter (only reached when source_type="all")
         if type_filter == "semantic_view":
             return "semantic_layer"
         return source_type
@@ -137,20 +160,24 @@ class GetCombinedDatasourceListCommand(BaseCommand):
     @staticmethod
     def _parse_filters(
         filters: list[dict[str, Any]],
-    ) -> tuple[str, str | None, bool | None, str | None]:
+    ) -> tuple[str, str | None, bool | None, str | None, int | None, str | None]:
         """
         Translate raw rison filter dicts into typed query parameters.
 
         Returns:
-            source_type:  "all" | "database" | "semantic_layer"
-            name_filter:  substring to match against name/table_name
-            sql_filter:   True → physical only, False → virtual only, None → both
-            type_filter:  "semantic_view" when the caller wants only semantic views
+            source_type:        "all" | "database" | "semantic_layer"
+            name_filter:        substring to match against name/table_name
+            sql_filter:         True → physical only, False → virtual only, None → both
+            type_filter:        "semantic_view" when the caller wants only semantic views
+            database_id:        filter datasets to a specific database ID
+            semantic_layer_uuid: filter semantic views to a specific semantic layer UUID
         """
         source_type = "all"
         name_filter: str | None = None
         sql_filter: bool | None = None
         type_filter: str | None = None
+        database_id: int | None = None
+        semantic_layer_uuid: str | None = None
 
         for f in filters:
             col = f.get("col")
@@ -166,5 +193,19 @@ class GetCombinedDatasourceListCommand(BaseCommand):
                     type_filter = "semantic_view"
                 elif opr == "dataset_is_null_or_empty" and isinstance(value, bool):
                     sql_filter = value
-
-        return source_type, name_filter, sql_filter, type_filter
+            elif col == "database" and value is not None:
+                try:
+                    database_id = int(value)
+                except (TypeError, ValueError):
+                    pass
+            elif col == "semantic_layer_uuid" and value is not None:
+                semantic_layer_uuid = str(value)
+
+        return (
+            source_type,
+            name_filter,
+            sql_filter,
+            type_filter,
+            database_id,
+            semantic_layer_uuid,
+        )
diff --git a/superset/daos/datasource.py b/superset/daos/datasource.py
index 60531606971..c917d05724e 100644
--- a/superset/daos/datasource.py
+++ b/superset/daos/datasource.py
@@ -95,6 +95,7 @@ class DatasourceDAO(BaseDAO[Datasource]):
     def build_dataset_query(
         name_filter: str | None,
         sql_filter: bool | None,
+        database_id: int | None = None,
     ) -> Select:
         """Build a SELECT for datasets, applying access and content filters."""
         ds_q = select(
@@ -121,11 +122,17 @@ class DatasourceDAO(BaseDAO[Datasource]):
             else:
                 ds_q = ds_q.where(and_(SqlaTable.sql.isnot(None), SqlaTable.sql != ""))
 
+        if database_id is not None:
+            ds_q = ds_q.where(SqlaTable.database_id == database_id)
+
         return ds_q
 
     @staticmethod
-    def build_semantic_view_query(name_filter: str | None) -> Select:
-        """Build a SELECT for semantic views, applying name filter."""
+    def build_semantic_view_query(
+        name_filter: str | None,
+        semantic_layer_uuid: str | None = None,
+    ) -> Select:
+        """Build a SELECT for semantic views, applying name and layer filters."""
         sv_q = select(
             SemanticView.id.label("item_id"),
             literal("semantic_layer").label("source_type"),
@@ -137,6 +144,9 @@ class DatasourceDAO(BaseDAO[Datasource]):
             escaped = _escape_ilike_fragment(name_filter)
             sv_q = sv_q.where(SemanticView.name.ilike(f"%{escaped}%", escape="\\"))
 
+        if semantic_layer_uuid is not None:
+            sv_q = sv_q.where(SemanticView.semantic_layer_uuid == semantic_layer_uuid)
+
         return sv_q
 
     @staticmethod


(superset) 24/25: Small fixes

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 152f11d1d414d5604fb7c319aea9ea99f4807ae3
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 21:59:33 2026 -0400

    Small fixes
---
 superset-core/src/superset_core/semantic_layers/view.py | 10 ++++------
 superset-frontend/src/pages/DatabaseList/index.tsx      |  2 +-
 superset-frontend/src/pages/DatasetList/index.tsx       |  2 +-
 3 files changed, 6 insertions(+), 8 deletions(-)

diff --git a/superset-core/src/superset_core/semantic_layers/view.py b/superset-core/src/superset_core/semantic_layers/view.py
index 02bcdb820c8..73ee35aee85 100644
--- a/superset-core/src/superset_core/semantic_layers/view.py
+++ b/superset-core/src/superset_core/semantic_layers/view.py
@@ -47,12 +47,10 @@ class SemanticView(ABC):
 
     features: frozenset[SemanticViewFeature]
 
-    @property
-    @abstractmethod
-    def name(self) -> str:
-        """
-        Returns the display name of the semantic view.
-        """
+    # Implementations must expose a display name for the view.
+    # Declared here as a type annotation (not abstract) so that existing
+    # implementations are not required to add a formal @abstractmethod.
+    name: str
 
     @abstractmethod
     def uid(self) -> str:
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index 1722d5dbc8b..ede9c604322 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -446,7 +446,7 @@ function DatabaseList({
                 items: [
                   {
                     key: 'database',
-                    label: databaseLabel(),
+                    label: t('Database'),
                     onClick: openDatabaseModal,
                   },
                   {
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 439cf481886..9bf60bb6705 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -1151,7 +1151,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
               items: [
                 {
                   key: 'dataset',
-                  label: datasetLabel(),
+                  label: t('Dataset'),
                   onClick: () => history.push('/dataset/add/'),
                 },
                 {


(superset) 07/25: Fix imports

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit a1a05134d23908da3dadc49d6f1f2902c2f1b740
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Mar 11 14:39:05 2026 -0400

    Fix imports
---
 superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx    | 2 +-
 superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
index 6b21e73e9ca..ced9992f427 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 import { useEffect } from 'react';
-import { t } from '@apache-superset/core';
+import { t } from '@apache-superset/core/translation';
 import { Spin } from 'antd';
 import { withJsonFormsControlProps } from '@jsonforms/react';
 import type {
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
index 505eab8f18b..1658a4cac35 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -17,8 +17,8 @@
  * under the License.
  */
 import { useState, useEffect, useCallback, useRef } from 'react';
-import { t } from '@apache-superset/core';
-import { styled } from '@apache-superset/core/ui';
+import { t } from '@apache-superset/core/translation';
+import { styled } from '@apache-superset/core/theme';
 import { SupersetClient } from '@superset-ui/core';
 import { Checkbox, Spin } from 'antd';
 import { Select } from '@superset-ui/core/components';


(superset) 01/25: feat: API for semantic layers

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit ebbf4777e374d256d4662eafae682c6d769c18c9
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Feb 11 16:02:37 2026 -0500

    feat: API for semantic layers
---
 pyproject.toml                                     |   1 +
 superset/commands/semantic_layer/create.py         |  64 ++
 superset/commands/semantic_layer/delete.py         |  56 ++
 superset/commands/semantic_layer/exceptions.py     |  27 +
 superset/commands/semantic_layer/update.py         |  41 +-
 superset/daos/semantic_layer.py                    |  15 +
 superset/initialization/__init__.py                |   6 +-
 superset/semantic_layers/api.py                    | 363 +++++++++++-
 superset/semantic_layers/schemas.py                |  15 +
 .../commands/semantic_layer/create_test.py         | 148 +++++
 .../commands/semantic_layer/delete_test.py         |  50 ++
 .../commands/semantic_layer/exceptions_test.py     |  43 ++
 .../commands/semantic_layer/update_test.py         | 117 +++-
 tests/unit_tests/semantic_layers/api_test.py       | 651 +++++++++++++++++++++
 tests/unit_tests/semantic_layers/schemas_test.py   | 131 ++++-
 15 files changed, 1719 insertions(+), 9 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 70feeeb4d3e..d222e3ccece 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -285,6 +285,7 @@ module = [
     "superset.tags.filters",
     "superset.commands.security.update",
     "superset.commands.security.create",
+    "superset.semantic_layers.api",
 ]
 warn_unused_ignores = false
 
diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py
new file mode 100644
index 00000000000..48ec0603c2a
--- /dev/null
+++ b/superset/commands/semantic_layer/create.py
@@ -0,0 +1,64 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import logging
+from functools import partial
+from typing import Any
+
+from flask_appbuilder.models.sqla import Model
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.commands.base import BaseCommand
+from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerInvalidError,
+)
+from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.semantic_layers.registry import registry
+from superset.utils.decorators import on_error, transaction
+
+logger = logging.getLogger(__name__)
+
+
+class CreateSemanticLayerCommand(BaseCommand):
+    def __init__(self, data: dict[str, Any]):
+        self._properties = data.copy()
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError, ValueError),
+            reraise=SemanticLayerCreateFailedError,
+        )
+    )
+    def run(self) -> Model:
+        self.validate()
+        return SemanticLayerDAO.create(attributes=self._properties)
+
+    def validate(self) -> None:
+        sl_type = self._properties.get("type")
+        if sl_type not in registry:
+            raise SemanticLayerInvalidError(f"Unknown type: {sl_type}")
+
+        name: str = self._properties.get("name", "")
+        if not SemanticLayerDAO.validate_uniqueness(name):
+            raise SemanticLayerInvalidError(f"Name already exists: {name}")
+
+        # Validate configuration against the plugin
+        cls = registry[sl_type]
+        cls.from_configuration(self._properties["configuration"])
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
new file mode 100644
index 00000000000..677126221a8
--- /dev/null
+++ b/superset/commands/semantic_layer/delete.py
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import logging
+from functools import partial
+
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset.commands.base import BaseCommand
+from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerDeleteFailedError,
+    SemanticLayerNotFoundError,
+)
+from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.semantic_layers.models import SemanticLayer
+from superset.utils.decorators import on_error, transaction
+
+logger = logging.getLogger(__name__)
+
+
+class DeleteSemanticLayerCommand(BaseCommand):
+    def __init__(self, uuid: str):
+        self._uuid = uuid
+        self._model: SemanticLayer | None = None
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError,),
+            reraise=SemanticLayerDeleteFailedError,
+        )
+    )
+    def run(self) -> None:
+        self.validate()
+        assert self._model
+        SemanticLayerDAO.delete([self._model])
+
+    def validate(self) -> None:
+        self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
+        if not self._model:
+            raise SemanticLayerNotFoundError()
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index 3e2fad94752..1b82a65b841 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -19,6 +19,8 @@ from flask_babel import lazy_gettext as _
 from superset.commands.exceptions import (
     CommandException,
     CommandInvalidError,
+    CreateFailedError,
+    DeleteFailedError,
     ForbiddenError,
     UpdateFailedError,
 )
@@ -39,3 +41,28 @@ class SemanticViewInvalidError(CommandInvalidError):
 
 class SemanticViewUpdateFailedError(UpdateFailedError):
     message = _("Semantic view could not be updated.")
+
+
+class SemanticLayerNotFoundError(CommandException):
+    status = 404
+    message = _("Semantic layer does not exist")
+
+
+class SemanticLayerForbiddenError(ForbiddenError):
+    message = _("Changing this semantic layer is forbidden")
+
+
+class SemanticLayerInvalidError(CommandInvalidError):
+    message = _("Semantic layer parameters are invalid.")
+
+
+class SemanticLayerCreateFailedError(CreateFailedError):
+    message = _("Semantic layer could not be created.")
+
+
+class SemanticLayerUpdateFailedError(UpdateFailedError):
+    message = _("Semantic layer could not be updated.")
+
+
+class SemanticLayerDeleteFailedError(DeleteFailedError):
+    message = _("Semantic layer could not be deleted.")
diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/update.py
index 55fbff34125..7487e17996a 100644
--- a/superset/commands/semantic_layer/update.py
+++ b/superset/commands/semantic_layer/update.py
@@ -26,13 +26,17 @@ from sqlalchemy.exc import SQLAlchemyError
 from superset import security_manager
 from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewNotFoundError,
     SemanticViewUpdateFailedError,
 )
-from superset.daos.semantic_layer import SemanticViewDAO
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
 from superset.exceptions import SupersetSecurityException
-from superset.semantic_layers.models import SemanticView
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.semantic_layers.registry import registry
 from superset.utils import json
 from superset.utils.decorators import on_error, transaction
 
@@ -83,3 +87,36 @@ class UpdateSemanticViewCommand(BaseCommand):
                 f"A semantic view with name '{name}' and the same "
                 "configuration already exists in this semantic layer."
             )
+
+
+class UpdateSemanticLayerCommand(BaseCommand):
+    def __init__(self, uuid: str, data: dict[str, Any]):
+        self._uuid = uuid
+        self._properties = data.copy()
+        self._model: SemanticLayer | None = None
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError, ValueError),
+            reraise=SemanticLayerUpdateFailedError,
+        )
+    )
+    def run(self) -> Model:
+        self.validate()
+        assert self._model
+        return SemanticLayerDAO.update(self._model, attributes=self._properties)
+
+    def validate(self) -> None:
+        self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
+        if not self._model:
+            raise SemanticLayerNotFoundError()
+
+        name = self._properties.get("name")
+        if name and not SemanticLayerDAO.validate_update_uniqueness(self._uuid, name):
+            raise SemanticLayerInvalidError(f"Name already exists: {name}")
+
+        if configuration := self._properties.get("configuration"):
+            sl_type = self._model.type
+            cls = registry[sl_type]
+            cls.from_configuration(configuration)
diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py
index 3ae4d97c431..8782973561c 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -38,6 +38,21 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO):
 
     model_cls = SemanticLayer
 
+    @staticmethod
+    def find_by_uuid(uuid_str: str) -> SemanticLayer | None:
+        try:
+            return (
+                db.session.query(SemanticLayer)
+                .filter(SemanticLayer.uuid == uuid_str)
+                .one_or_none()
+            )
+        except ValueError:
+            return None
+
+    @classmethod
+    def find_all(cls, skip_base_filter: bool = False) -> list[SemanticLayer]:
+        return db.session.query(SemanticLayer).all()
+
     @classmethod
     def validate_uniqueness(cls, name: str) -> bool:
         """
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 62075dac5bc..c8ec9bbe516 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -269,8 +269,12 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         appbuilder.add_api(RLSRestApi)
         appbuilder.add_api(SavedQueryRestApi)
         if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
-            from superset.semantic_layers.api import SemanticViewRestApi
+            from superset.semantic_layers.api import (
+                SemanticLayerRestApi,
+                SemanticViewRestApi,
+            )
 
+            appbuilder.add_api(SemanticLayerRestApi)
             appbuilder.add_api(SemanticViewRestApi)
         appbuilder.add_api(TagRestApi)
         appbuilder.add_api(SqlLabRestApi)
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 6cf5ea0e5e9..eb10205106a 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -17,24 +17,43 @@
 from __future__ import annotations
 
 import logging
+from typing import Any
 
 from flask import request, Response
-from flask_appbuilder.api import expose, protect
+from flask_appbuilder.api import expose, protect, safe
 from flask_appbuilder.models.sqla.interface import SQLAInterface
 from marshmallow import ValidationError
 
 from superset import event_logger
+from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
+from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
     SemanticViewUpdateFailedError,
 )
-from superset.commands.semantic_layer.update import UpdateSemanticViewCommand
+from superset.commands.semantic_layer.update import (
+    UpdateSemanticLayerCommand,
+    UpdateSemanticViewCommand,
+)
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
-from superset.semantic_layers.models import SemanticView
-from superset.semantic_layers.schemas import SemanticViewPutSchema
+from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.semantic_layers.registry import registry
+from superset.semantic_layers.schemas import (
+    SemanticLayerPostSchema,
+    SemanticLayerPutSchema,
+    SemanticViewPutSchema,
+)
+from superset.superset_typing import FlaskResponse
 from superset.views.base_api import (
+    BaseSupersetApi,
     BaseSupersetModelRestApi,
     requires_json,
     statsd_metrics,
@@ -43,6 +62,16 @@ from superset.views.base_api import (
 logger = logging.getLogger(__name__)
 
 
+def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]:
+    return {
+        "uuid": str(layer.uuid),
+        "name": layer.name,
+        "description": layer.description,
+        "type": layer.type,
+        "cache_timeout": layer.cache_timeout,
+    }
+
+
 class SemanticViewRestApi(BaseSupersetModelRestApi):
     datamodel = SQLAInterface(SemanticView)
 
@@ -126,3 +155,329 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
             )
             response = self.response_422(message=str(ex))
         return response
+
+
+class SemanticLayerRestApi(BaseSupersetApi):
+    resource_name = "semantic_layer"
+    allow_browser_login = True
+    class_permission_name = "SemanticLayer"
+    method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
+    openapi_spec_tag = "Semantic Layers"
+
+    @expose("/types", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def types(self) -> FlaskResponse:
+        """List available semantic layer types.
+        ---
+        get:
+          summary: List available semantic layer types
+          responses:
+            200:
+              description: A list of semantic layer types
+            401:
+              $ref: '#/components/responses/401'
+        """
+        result = [
+            {"id": key, "name": cls.name, "description": cls.description}  # type: ignore[attr-defined]
+            for key, cls in registry.items()
+        ]
+        return self.response(200, result=result)
+
+    @expose("/schema/configuration", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @requires_json
+    def configuration_schema(self) -> FlaskResponse:
+        """Get configuration schema for a semantic layer type.
+        ---
+        post:
+          summary: Get configuration schema for a semantic layer type
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    type:
+                      type: string
+                    configuration:
+                      type: object
+          responses:
+            200:
+              description: Configuration JSON Schema
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+        """
+        body = request.json or {}
+        sl_type = body.get("type")
+
+        cls = registry.get(sl_type)  # type: ignore[arg-type]
+        if not cls:
+            return self.response_400(message=f"Unknown type: {sl_type}")
+
+        parsed_config = None
+        if config := body.get("configuration"):
+            try:
+                parsed_config = cls.from_configuration(config).configuration  # type: ignore[attr-defined]
+            except Exception:  # pylint: disable=broad-except
+                parsed_config = None
+
+        schema = cls.get_configuration_schema(parsed_config)
+        return self.response(200, result=schema)
+
+    @expose("/<uuid>/schema/runtime", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def runtime_schema(self, uuid: str) -> FlaskResponse:
+        """Get runtime schema for a stored semantic layer.
+        ---
+        post:
+          summary: Get runtime schema for a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    runtime_data:
+                      type: object
+          responses:
+            200:
+              description: Runtime JSON Schema
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+
+        body = request.get_json(silent=True) or {}
+        runtime_data = body.get("runtime_data")
+
+        cls = registry.get(layer.type)
+        if not cls:
+            return self.response_400(message=f"Unknown type: {layer.type}")
+
+        try:
+            schema = cls.get_runtime_schema(
+                layer.implementation.configuration,  # type: ignore[attr-defined]
+                runtime_data,
+            )
+        except Exception as ex:  # pylint: disable=broad-except
+            return self.response_400(message=str(ex))
+
+        return self.response(200, result=schema)
+
+    @expose("/", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @requires_json
+    def post(self) -> FlaskResponse:
+        """Create a semantic layer.
+        ---
+        post:
+          summary: Create a semantic layer
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                    description:
+                      type: string
+                    type:
+                      type: string
+                    configuration:
+                      type: object
+                    cache_timeout:
+                      type: integer
+          responses:
+            201:
+              description: Semantic layer created
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            item = SemanticLayerPostSchema().load(request.json)
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+
+        try:
+            new_model = CreateSemanticLayerCommand(item).run()
+            return self.response(201, result={"uuid": str(new_model.uuid)})
+        except SemanticLayerInvalidError as ex:
+            return self.response_422(message=str(ex))
+        except SemanticLayerCreateFailedError as ex:
+            logger.error(
+                "Error creating semantic layer: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<uuid>", methods=("PUT",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @requires_json
+    def put(self, uuid: str) -> FlaskResponse:
+        """Update a semantic layer.
+        ---
+        put:
+          summary: Update a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    name:
+                      type: string
+                    description:
+                      type: string
+                    configuration:
+                      type: object
+                    cache_timeout:
+                      type: integer
+          responses:
+            200:
+              description: Semantic layer updated
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            item = SemanticLayerPutSchema().load(request.json)
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+
+        try:
+            changed_model = UpdateSemanticLayerCommand(uuid, item).run()
+            return self.response(200, result={"uuid": str(changed_model.uuid)})
+        except SemanticLayerNotFoundError:
+            return self.response_404()
+        except SemanticLayerInvalidError as ex:
+            return self.response_422(message=str(ex))
+        except SemanticLayerUpdateFailedError as ex:
+            logger.error(
+                "Error updating semantic layer: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<uuid>", methods=("DELETE",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def delete(self, uuid: str) -> FlaskResponse:
+        """Delete a semantic layer.
+        ---
+        delete:
+          summary: Delete a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          responses:
+            200:
+              description: Semantic layer deleted
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            DeleteSemanticLayerCommand(uuid).run()
+            return self.response(200, message="OK")
+        except SemanticLayerNotFoundError:
+            return self.response_404()
+        except SemanticLayerDeleteFailedError as ex:
+            logger.error(
+                "Error deleting semantic layer: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def get_list(self) -> FlaskResponse:
+        """List all semantic layers.
+        ---
+        get:
+          summary: List all semantic layers
+          responses:
+            200:
+              description: A list of semantic layers
+            401:
+              $ref: '#/components/responses/401'
+        """
+        layers = SemanticLayerDAO.find_all()
+        result = [_serialize_layer(layer) for layer in layers]
+        return self.response(200, result=result)
+
+    @expose("/<uuid>", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def get(self, uuid: str) -> FlaskResponse:
+        """Get a single semantic layer.
+        ---
+        get:
+          summary: Get a semantic layer by UUID
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          responses:
+            200:
+              description: A semantic layer
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+        return self.response(200, result=_serialize_layer(layer))
diff --git a/superset/semantic_layers/schemas.py b/superset/semantic_layers/schemas.py
index b86a5398937..d10e0fd28fb 100644
--- a/superset/semantic_layers/schemas.py
+++ b/superset/semantic_layers/schemas.py
@@ -20,3 +20,18 @@ from marshmallow import fields, Schema
 class SemanticViewPutSchema(Schema):
     description = fields.String(allow_none=True)
     cache_timeout = fields.Integer(allow_none=True)
+
+
+class SemanticLayerPostSchema(Schema):
+    name = fields.String(required=True)
+    description = fields.String(allow_none=True)
+    type = fields.String(required=True)
+    configuration = fields.Dict(required=True)
+    cache_timeout = fields.Integer(allow_none=True)
+
+
+class SemanticLayerPutSchema(Schema):
+    name = fields.String()
+    description = fields.String(allow_none=True)
+    configuration = fields.Dict()
+    cache_timeout = fields.Integer(allow_none=True)
diff --git a/tests/unit_tests/commands/semantic_layer/create_test.py b/tests/unit_tests/commands/semantic_layer/create_test.py
new file mode 100644
index 00000000000..3beceebc3b8
--- /dev/null
+++ b/tests/unit_tests/commands/semantic_layer/create_test.py
@@ -0,0 +1,148 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
+from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerInvalidError,
+)
+
+
+def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
+    """Test successful creation of a semantic layer."""
+    new_model = MagicMock()
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = True
+    dao.create.return_value = new_model
+
+    mock_cls = MagicMock()
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": mock_cls},
+    )
+
+    data = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    result = CreateSemanticLayerCommand(data).run()
+
+    assert result == new_model
+    dao.create.assert_called_once_with(attributes=data)
+    mock_cls.from_configuration.assert_called_once_with({"account": "test"})
+
+
+def test_create_semantic_layer_unknown_type(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerInvalidError is raised for unknown type."""
+    mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {},
+        clear=True,
+    )
+
+    data = {
+        "name": "My Layer",
+        "type": "nonexistent",
+        "configuration": {},
+    }
+    with pytest.raises(SemanticLayerInvalidError):
+        CreateSemanticLayerCommand(data).run()
+
+
+def test_create_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerInvalidError is raised for duplicate names."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = False
+
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": MagicMock()},
+    )
+
+    data = {
+        "name": "Duplicate",
+        "type": "snowflake",
+        "configuration": {},
+    }
+    with pytest.raises(SemanticLayerInvalidError):
+        CreateSemanticLayerCommand(data).run()
+
+
+def test_create_semantic_layer_invalid_configuration(
+    mocker: MockerFixture,
+) -> None:
+    """Test that invalid configuration is caught by the @transaction decorator."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = True
+
+    mock_cls = MagicMock()
+    mock_cls.from_configuration.side_effect = ValueError("bad config")
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": mock_cls},
+    )
+
+    data = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"bad": "data"},
+    }
+    with pytest.raises(SemanticLayerCreateFailedError):
+        CreateSemanticLayerCommand(data).run()
+
+
+def test_create_semantic_layer_copies_data(mocker: MockerFixture) -> None:
+    """Test that the command copies input data and does not mutate it."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao.validate_uniqueness.return_value = True
+    dao.create.return_value = MagicMock()
+
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.create.registry",
+        {"snowflake": MagicMock()},
+    )
+
+    original_data = {
+        "name": "Original",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    CreateSemanticLayerCommand(original_data).run()
+
+    assert original_data == {
+        "name": "Original",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
new file mode 100644
index 00000000000..456af8e390a
--- /dev/null
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
+from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
+
+
+def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
+    """Test successful deletion of a semantic layer."""
+    mock_model = MagicMock()
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+
+    DeleteSemanticLayerCommand("some-uuid").run()
+
+    dao.find_by_uuid.assert_called_once_with("some-uuid")
+    dao.delete.assert_called_once_with([mock_model])
+
+
+def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerNotFoundError is raised when model is missing."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = None
+
+    with pytest.raises(SemanticLayerNotFoundError):
+        DeleteSemanticLayerCommand("missing-uuid").run()
diff --git a/tests/unit_tests/commands/semantic_layer/exceptions_test.py b/tests/unit_tests/commands/semantic_layer/exceptions_test.py
index fb6728a36ab..91aa4329228 100644
--- a/tests/unit_tests/commands/semantic_layer/exceptions_test.py
+++ b/tests/unit_tests/commands/semantic_layer/exceptions_test.py
@@ -16,6 +16,12 @@
 # under the License.
 
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerForbiddenError,
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -46,3 +52,40 @@ def test_semantic_view_update_failed_error() -> None:
     """Test SemanticViewUpdateFailedError has correct message."""
     error = SemanticViewUpdateFailedError()
     assert str(error.message) == "Semantic view could not be updated."
+
+
+def test_semantic_layer_not_found_error() -> None:
+    """Test SemanticLayerNotFoundError has correct status and message."""
+    error = SemanticLayerNotFoundError()
+    assert error.status == 404
+    assert str(error.message) == "Semantic layer does not exist"
+
+
+def test_semantic_layer_forbidden_error() -> None:
+    """Test SemanticLayerForbiddenError has correct message."""
+    error = SemanticLayerForbiddenError()
+    assert str(error.message) == "Changing this semantic layer is forbidden"
+
+
+def test_semantic_layer_invalid_error() -> None:
+    """Test SemanticLayerInvalidError has correct message."""
+    error = SemanticLayerInvalidError()
+    assert str(error.message) == "Semantic layer parameters are invalid."
+
+
+def test_semantic_layer_create_failed_error() -> None:
+    """Test SemanticLayerCreateFailedError has correct message."""
+    error = SemanticLayerCreateFailedError()
+    assert str(error.message) == "Semantic layer could not be created."
+
+
+def test_semantic_layer_update_failed_error() -> None:
+    """Test SemanticLayerUpdateFailedError has correct message."""
+    error = SemanticLayerUpdateFailedError()
+    assert str(error.message) == "Semantic layer could not be updated."
+
+
+def test_semantic_layer_delete_failed_error() -> None:
+    """Test SemanticLayerDeleteFailedError has correct message."""
+    error = SemanticLayerDeleteFailedError()
+    assert str(error.message) == "Semantic layer could not be deleted."
diff --git a/tests/unit_tests/commands/semantic_layer/update_test.py b/tests/unit_tests/commands/semantic_layer/update_test.py
index 467926fc2c5..1b144428e89 100644
--- a/tests/unit_tests/commands/semantic_layer/update_test.py
+++ b/tests/unit_tests/commands/semantic_layer/update_test.py
@@ -21,10 +21,15 @@ import pytest
 from pytest_mock import MockerFixture
 
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
     SemanticViewForbiddenError,
     SemanticViewNotFoundError,
 )
-from superset.commands.semantic_layer.update import UpdateSemanticViewCommand
+from superset.commands.semantic_layer.update import (
+    UpdateSemanticLayerCommand,
+    UpdateSemanticViewCommand,
+)
 from superset.exceptions import SupersetSecurityException
 
 
@@ -106,6 +111,116 @@ def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
     assert original_data == {"description": "Original"}
 
 
+# =============================================================================
+# UpdateSemanticLayerCommand tests
+# =============================================================================
+
+
+def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
+    """Test successful update of a semantic layer."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    data = {"name": "Updated", "description": "New desc"}
+    result = UpdateSemanticLayerCommand("some-uuid", data).run()
+
+    assert result == mock_model
+    dao.find_by_uuid.assert_called_once_with("some-uuid")
+    dao.update.assert_called_once_with(mock_model, attributes=data)
+
+
+def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerNotFoundError is raised when model is missing."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = None
+
+    with pytest.raises(SemanticLayerNotFoundError):
+        UpdateSemanticLayerCommand("missing-uuid", {"name": "test"}).run()
+
+
+def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
+    """Test that SemanticLayerInvalidError is raised for duplicate names."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.validate_update_uniqueness.return_value = False
+
+    with pytest.raises(SemanticLayerInvalidError):
+        UpdateSemanticLayerCommand("some-uuid", {"name": "Duplicate"}).run()
+
+
+def test_update_semantic_layer_validates_configuration(
+    mocker: MockerFixture,
+) -> None:
+    """Test that configuration is validated against the plugin."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    mock_cls = MagicMock()
+    mocker.patch.dict(
+        "superset.commands.semantic_layer.update.registry",
+        {"snowflake": mock_cls},
+    )
+
+    config = {"account": "test"}
+    UpdateSemanticLayerCommand("some-uuid", {"configuration": config}).run()
+
+    mock_cls.from_configuration.assert_called_once_with(config)
+
+
+def test_update_semantic_layer_skips_name_check_when_no_name(
+    mocker: MockerFixture,
+) -> None:
+    """Test that name uniqueness is not checked when name is not provided."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    UpdateSemanticLayerCommand("some-uuid", {"description": "Updated"}).run()
+
+    dao.validate_update_uniqueness.assert_not_called()
+
+
+def test_update_semantic_layer_copies_data(mocker: MockerFixture) -> None:
+    """Test that the command copies input data and does not mutate it."""
+    mock_model = MagicMock()
+    mock_model.type = "snowflake"
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.update.SemanticLayerDAO",
+    )
+    dao.find_by_uuid.return_value = mock_model
+    dao.update.return_value = mock_model
+
+    original_data = {"description": "Original"}
+    UpdateSemanticLayerCommand("some-uuid", original_data).run()
+
+    assert original_data == {"description": "Original"}
+
+
 def _make_view_model(
     uuid: str = "view-uuid-1",
     name: str = "my_view",
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index db77ec9670f..08e1a1ab796 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import uuid as uuid_lib
 from typing import Any
 from unittest.mock import MagicMock
 
@@ -22,6 +23,11 @@ import pytest
 from pytest_mock import MockerFixture
 
 from superset.commands.semantic_layer.exceptions import (
+    SemanticLayerCreateFailedError,
+    SemanticLayerDeleteFailedError,
+    SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticLayerUpdateFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -259,3 +265,648 @@ def test_put_semantic_view_empty_payload(
     )
 
     assert response.status_code == 200
+
+
+# =============================================================================
+# SemanticLayerRestApi tests
+# =============================================================================
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_types(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /types returns registered semantic layer types."""
+    mock_cls = MagicMock()
+    mock_cls.name = "Snowflake Semantic Layer"
+    mock_cls.description = "Connect to Snowflake."
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.get("/api/v1/semantic_layer/types")
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert len(result) == 1
+    assert result[0] == {
+        "id": "snowflake",
+        "name": "Snowflake Semantic Layer",
+        "description": "Connect to Snowflake.",
+    }
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_types_empty(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /types returns empty list when no types registered."""
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {},
+        clear=True,
+    )
+
+    response = client.get("/api/v1/semantic_layer/types")
+
+    assert response.status_code == 200
+    assert response.json["result"] == []
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /schema/configuration returns schema without partial config."""
+    mock_cls = MagicMock()
+    mock_cls.get_configuration_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "snowflake"},
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"] == {"type": "object"}
+    mock_cls.get_configuration_schema.assert_called_once_with(None)
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_with_partial_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /schema/configuration enriches schema with partial config."""
+    mock_instance = MagicMock()
+    mock_instance.configuration = {"account": "test"}
+
+    mock_cls = MagicMock()
+    mock_cls.from_configuration.return_value = mock_instance
+    mock_cls.get_configuration_schema.return_value = {
+        "type": "object",
+        "properties": {"database": {"enum": ["db1", "db2"]}},
+    }
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "snowflake", "configuration": {"account": "test"}},
+    )
+
+    assert response.status_code == 200
+    mock_cls.get_configuration_schema.assert_called_once_with({"account": "test"})
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_with_invalid_partial_config(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test /schema/configuration returns schema when partial config fails."""
+    mock_cls = MagicMock()
+    mock_cls.from_configuration.side_effect = ValueError("bad config")
+    mock_cls.get_configuration_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "snowflake", "configuration": {"bad": "data"}},
+    )
+
+    assert response.status_code == 200
+    mock_cls.get_configuration_schema.assert_called_once_with(None)
+
+
+@SEMANTIC_LAYERS_APP
+def test_configuration_schema_unknown_type(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /schema/configuration returns 400 for unknown type."""
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {},
+        clear=True,
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/schema/configuration",
+        json={"type": "nonexistent"},
+    )
+
+    assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns runtime schema."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "snowflake"
+    mock_layer.implementation.configuration = {"account": "test"}
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mock_cls = MagicMock()
+    mock_cls.get_runtime_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+        json={"runtime_data": {"database": "mydb"}},
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"] == {"type": "object"}
+    mock_cls.get_runtime_schema.assert_called_once_with(
+        {"account": "test"}, {"database": "mydb"}
+    )
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_no_body(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime works without a request body."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "snowflake"
+    mock_layer.implementation.configuration = {"account": "test"}
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mock_cls = MagicMock()
+    mock_cls.get_runtime_schema.return_value = {"type": "object"}
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+    )
+
+    assert response.status_code == 200
+    mock_cls.get_runtime_schema.assert_called_once_with({"account": "test"}, None)
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns 404 when layer not found."""
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = None
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}/schema/runtime",
+    )
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_unknown_type(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns 400 for unknown type."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "unknown_type"
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+    )
+
+    assert response.status_code == 400
+    assert "Unknown type" in response.json["message"]
+
+
+@SEMANTIC_LAYERS_APP
+def test_runtime_schema_exception(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST /<uuid>/schema/runtime returns 400 when schema raises."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_layer = MagicMock()
+    mock_layer.type = "snowflake"
+    mock_layer.implementation.configuration = {"account": "test"}
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = mock_layer
+
+    mock_cls = MagicMock()
+    mock_cls.get_runtime_schema.side_effect = ValueError("Bad config")
+
+    mocker.patch.dict(
+        "superset.semantic_layers.api.registry",
+        {"snowflake": mock_cls},
+        clear=True,
+    )
+
+    response = client.post(
+        f"/api/v1/semantic_layer/{test_uuid}/schema/runtime",
+    )
+
+    assert response.status_code == 400
+    assert "Bad config" in response.json["message"]
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / creates a semantic layer."""
+    test_uuid = uuid_lib.uuid4()
+    new_model = MagicMock()
+    new_model.uuid = test_uuid
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.return_value = new_model
+
+    payload = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    response = client.post("/api/v1/semantic_layer/", json=payload)
+
+    assert response.status_code == 201
+    assert response.json["result"]["uuid"] == str(test_uuid)
+    mock_command.assert_called_once_with(payload)
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer_invalid(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / returns 422 when validation fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerInvalidError(
+        "Unknown type: bad"
+    )
+
+    payload = {
+        "name": "My Layer",
+        "type": "bad",
+        "configuration": {},
+    }
+    response = client.post("/api/v1/semantic_layer/", json=payload)
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer_create_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / returns 422 when creation fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerCreateFailedError()
+
+    payload = {
+        "name": "My Layer",
+        "type": "snowflake",
+        "configuration": {"account": "test"},
+    }
+    response = client.post("/api/v1/semantic_layer/", json=payload)
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_post_semantic_layer_missing_required_fields(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test POST / returns 400 when required fields are missing."""
+    mocker.patch(
+        "superset.semantic_layers.api.CreateSemanticLayerCommand",
+    )
+
+    response = client.post(
+        "/api/v1/semantic_layer/",
+        json={"name": "Only name"},
+    )
+
+    assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> updates a semantic layer."""
+    test_uuid = uuid_lib.uuid4()
+    changed_model = MagicMock()
+    changed_model.uuid = test_uuid
+
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.return_value = changed_model
+
+    payload = {"name": "Updated Name"}
+    response = client.put(
+        f"/api/v1/semantic_layer/{test_uuid}",
+        json=payload,
+    )
+
+    assert response.status_code == 200
+    assert response.json["result"]["uuid"] == str(test_uuid)
+    mock_command.assert_called_once_with(str(test_uuid), payload)
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> returns 404 when layer not found."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerNotFoundError()
+
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"name": "New"},
+    )
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_invalid(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> returns 422 when validation fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerInvalidError(
+        "Name already exists"
+    )
+
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"name": "Duplicate"},
+    )
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_update_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test PUT /<uuid> returns 422 when update fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.UpdateSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerUpdateFailedError()
+
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"name": "Test"},
+    )
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_put_semantic_layer_validation_error(
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """Test PUT /<uuid> returns 400 when payload fails schema validation."""
+    response = client.put(
+        f"/api/v1/semantic_layer/{uuid_lib.uuid4()}",
+        json={"cache_timeout": "not_a_number"},
+    )
+
+    assert response.status_code == 400
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<uuid> deletes a semantic layer."""
+    test_uuid = str(uuid_lib.uuid4())
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticLayerCommand",
+    )
+    mock_command.return_value.run.return_value = None
+
+    response = client.delete(f"/api/v1/semantic_layer/{test_uuid}")
+
+    assert response.status_code == 200
+    mock_command.assert_called_once_with(test_uuid)
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_layer_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<uuid> returns 404 when layer not found."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerNotFoundError()
+
+    response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
+
+    assert response.status_code == 404
+
+
+@SEMANTIC_LAYERS_APP
+def test_delete_semantic_layer_failed(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test DELETE /<uuid> returns 422 when deletion fails."""
+    mock_command = mocker.patch(
+        "superset.semantic_layers.api.DeleteSemanticLayerCommand",
+    )
+    mock_command.return_value.run.side_effect = SemanticLayerDeleteFailedError()
+
+    response = client.delete(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
+
+    assert response.status_code == 422
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_list_semantic_layers(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET / returns list of semantic layers."""
+    layer1 = MagicMock()
+    layer1.uuid = uuid_lib.uuid4()
+    layer1.name = "Layer 1"
+    layer1.description = "First"
+    layer1.type = "snowflake"
+    layer1.cache_timeout = None
+
+    layer2 = MagicMock()
+    layer2.uuid = uuid_lib.uuid4()
+    layer2.name = "Layer 2"
+    layer2.description = None
+    layer2.type = "snowflake"
+    layer2.cache_timeout = 300
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_all.return_value = [layer1, layer2]
+
+    response = client.get("/api/v1/semantic_layer/")
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert len(result) == 2
+    assert result[0]["name"] == "Layer 1"
+    assert result[1]["name"] == "Layer 2"
+    assert result[1]["cache_timeout"] == 300
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_list_semantic_layers_empty(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET / returns empty list when no layers exist."""
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_all.return_value = []
+
+    response = client.get("/api/v1/semantic_layer/")
+
+    assert response.status_code == 200
+    assert response.json["result"] == []
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_semantic_layer(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /<uuid> returns a single semantic layer."""
+    test_uuid = uuid_lib.uuid4()
+    layer = MagicMock()
+    layer.uuid = test_uuid
+    layer.name = "My Layer"
+    layer.description = "A layer"
+    layer.type = "snowflake"
+    layer.cache_timeout = 600
+
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = layer
+
+    response = client.get(f"/api/v1/semantic_layer/{test_uuid}")
+
+    assert response.status_code == 200
+    result = response.json["result"]
+    assert result["uuid"] == str(test_uuid)
+    assert result["name"] == "My Layer"
+    assert result["type"] == "snowflake"
+    assert result["cache_timeout"] == 600
+
+
+@SEMANTIC_LAYERS_APP
+def test_get_semantic_layer_not_found(
+    client: Any,
+    full_api_access: None,
+    mocker: MockerFixture,
+) -> None:
+    """Test GET /<uuid> returns 404 when layer not found."""
+    mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
+    mock_dao.find_by_uuid.return_value = None
+
+    response = client.get(f"/api/v1/semantic_layer/{uuid_lib.uuid4()}")
+
+    assert response.status_code == 404
diff --git a/tests/unit_tests/semantic_layers/schemas_test.py b/tests/unit_tests/semantic_layers/schemas_test.py
index 739544cdadc..76529621042 100644
--- a/tests/unit_tests/semantic_layers/schemas_test.py
+++ b/tests/unit_tests/semantic_layers/schemas_test.py
@@ -18,7 +18,11 @@
 import pytest
 from marshmallow import ValidationError
 
-from superset.semantic_layers.schemas import SemanticViewPutSchema
+from superset.semantic_layers.schemas import (
+    SemanticLayerPostSchema,
+    SemanticLayerPutSchema,
+    SemanticViewPutSchema,
+)
 
 
 def test_semantic_view_put_schema_both_fields() -> None:
@@ -77,3 +81,128 @@ def test_semantic_view_put_schema_unknown_field() -> None:
     with pytest.raises(ValidationError) as exc_info:
         schema.load({"unknown_field": "value"})
     assert "unknown_field" in exc_info.value.messages
+
+
+# =============================================================================
+# SemanticLayerPostSchema tests
+# =============================================================================
+
+
+def test_post_schema_all_fields() -> None:
+    """Test loading all fields."""
+    schema = SemanticLayerPostSchema()
+    result = schema.load(
+        {
+            "name": "My Layer",
+            "description": "A layer",
+            "type": "snowflake",
+            "configuration": {"account": "test"},
+            "cache_timeout": 300,
+        }
+    )
+    assert result["name"] == "My Layer"
+    assert result["type"] == "snowflake"
+    assert result["configuration"] == {"account": "test"}
+    assert result["cache_timeout"] == 300
+
+
+def test_post_schema_required_fields_only() -> None:
+    """Test loading with only required fields."""
+    schema = SemanticLayerPostSchema()
+    result = schema.load(
+        {
+            "name": "My Layer",
+            "type": "snowflake",
+            "configuration": {"account": "test"},
+        }
+    )
+    assert result["name"] == "My Layer"
+    assert "description" not in result
+    assert "cache_timeout" not in result
+
+
+def test_post_schema_missing_name() -> None:
+    """Test that missing name raises ValidationError."""
+    schema = SemanticLayerPostSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"type": "snowflake", "configuration": {}})
+    assert "name" in exc_info.value.messages
+
+
+def test_post_schema_missing_type() -> None:
+    """Test that missing type raises ValidationError."""
+    schema = SemanticLayerPostSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"name": "My Layer", "configuration": {}})
+    assert "type" in exc_info.value.messages
+
+
+def test_post_schema_missing_configuration() -> None:
+    """Test that missing configuration raises ValidationError."""
+    schema = SemanticLayerPostSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"name": "My Layer", "type": "snowflake"})
+    assert "configuration" in exc_info.value.messages
+
+
+def test_post_schema_null_description() -> None:
+    """Test that description accepts None."""
+    schema = SemanticLayerPostSchema()
+    result = schema.load(
+        {
+            "name": "My Layer",
+            "type": "snowflake",
+            "configuration": {},
+            "description": None,
+        }
+    )
+    assert result["description"] is None
+
+
+# =============================================================================
+# SemanticLayerPutSchema tests
+# =============================================================================
+
+
+def test_put_schema_all_fields() -> None:
+    """Test loading all fields."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load(
+        {
+            "name": "Updated",
+            "description": "New desc",
+            "configuration": {"account": "new"},
+            "cache_timeout": 600,
+        }
+    )
+    assert result["name"] == "Updated"
+    assert result["configuration"] == {"account": "new"}
+
+
+def test_put_schema_empty() -> None:
+    """Test loading empty payload."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load({})
+    assert result == {}
+
+
+def test_put_schema_name_only() -> None:
+    """Test loading with only name."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load({"name": "New Name"})
+    assert result == {"name": "New Name"}
+
+
+def test_put_schema_configuration_only() -> None:
+    """Test loading with only configuration."""
+    schema = SemanticLayerPutSchema()
+    result = schema.load({"configuration": {"key": "value"}})
+    assert result == {"configuration": {"key": "value"}}
+
+
+def test_put_schema_unknown_field() -> None:
+    """Test that unknown fields raise ValidationError."""
+    schema = SemanticLayerPutSchema()
+    with pytest.raises(ValidationError) as exc_info:
+        schema.load({"unknown_field": "value"})
+    assert "unknown_field" in exc_info.value.messages


(superset) 13/25: chore: rename database/database when using semantic layers

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1b089f828aef52cf399da2e80f2d209f981424f1
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Mar 12 10:12:32 2026 -0400

    chore: rename database/database when using semantic layers
---
 superset-frontend/src/features/home/RightMenu.tsx  | 14 ++--
 superset-frontend/src/pages/DatabaseList/index.tsx | 28 +++++---
 superset-frontend/src/pages/DatasetList/index.tsx  | 78 ++++++++++++++++------
 superset-frontend/src/utils/semanticLayerLabels.ts | 63 +++++++++++++++++
 4 files changed, 146 insertions(+), 37 deletions(-)

diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx
index 28c25498642..ea35f46db16 100644
--- a/superset-frontend/src/features/home/RightMenu.tsx
+++ b/superset-frontend/src/features/home/RightMenu.tsx
@@ -66,6 +66,10 @@ import {
   RightMenuProps,
 } from './types';
 import { NAVBAR_MENU_POPUP_OFFSET } from './commonMenuData';
+import {
+  datasetLabelLower,
+  databaseLabelLower,
+} from 'src/utils/semanticLayerLabels';
 
 const extensionsRegistry = getExtensionsRegistry();
 
@@ -176,12 +180,12 @@ const RightMenu = ({
       icon: <Icons.DatabaseOutlined data-test={`menu-item-${t('Data')}`} />,
       childs: [
         {
-          label: t('Connect database'),
+          label: t('Connect %s', databaseLabelLower()),
           name: GlobalMenuDataOptions.DbConnection,
           perm: canDatabase && !nonExamplesDBConnected,
         },
         {
-          label: t('Create dataset'),
+          label: t('Create %s', datasetLabelLower()),
           name: GlobalMenuDataOptions.DatasetCreation,
           url: '/dataset/add/',
           perm: canDataset && nonExamplesDBConnected,
@@ -192,19 +196,19 @@ const RightMenu = ({
           perm: canDatabase && HAS_GSHEETS_INSTALLED,
         },
         {
-          label: t('Upload CSV to database'),
+          label: t('Upload CSV to %s', databaseLabelLower()),
           name: GlobalMenuDataOptions.CSVUpload,
           perm: canUploadCSV && showUploads,
           disable: isAdmin && !allowUploads,
         },
         {
-          label: t('Upload Excel to database'),
+          label: t('Upload Excel to %s', databaseLabelLower()),
           name: GlobalMenuDataOptions.ExcelUpload,
           perm: canUploadExcel && showUploads,
           disable: isAdmin && !allowUploads,
         },
         {
-          label: t('Upload Columnar file to database'),
+          label: t('Upload Columnar file to %s', databaseLabelLower()),
           name: GlobalMenuDataOptions.ColumnarUpload,
           perm: canUploadColumnar && showUploads,
           disable: isAdmin && !allowUploads,
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index a263fe2d2b4..c428d84b229 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -68,6 +68,11 @@ import { DatabaseObject } from 'src/features/databases/types';
 import { QueryObjectColumns } from 'src/views/CRUD/types';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
 import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
+import {
+  databaseLabel,
+  databaseLabelLower,
+  databasesLabel,
+} from 'src/utils/semanticLayerLabels';
 
 const extensionsRegistry = getExtensionsRegistry();
 const DatabaseDeleteRelatedExtension = extensionsRegistry.get(
@@ -137,7 +142,7 @@ function DatabaseList({
     refreshData: dbRefreshData,
   } = useListViewResource<DatabaseObject>(
     'database',
-    t('database'),
+    databaseLabelLower(),
     addDangerToast,
   );
 
@@ -423,7 +428,7 @@ function DatabaseList({
   const menuData: SubMenuProps = {
     activeChild: 'Databases',
     dropDownLinks: filteredDropDown,
-    name: t('Databases'),
+    name: databasesLabel(),
   };
 
   if (canCreate) {
@@ -441,7 +446,7 @@ function DatabaseList({
                 items: [
                   {
                     key: 'database',
-                    label: t('Database'),
+                    label: databaseLabel(),
                     onClick: openDatabaseModal,
                   },
                   {
@@ -478,7 +483,7 @@ function DatabaseList({
         {
           'data-test': 'btn-create-database',
           icon: <Icons.PlusOutlined iconSize="m" />,
-          name: t('Database'),
+          name: databaseLabel(),
           buttonStyle: 'primary',
           onClick: openDatabaseModal,
         },
@@ -499,7 +504,9 @@ function DatabaseList({
         });
       } catch (error) {
         setPreparingExport(false);
-        addDangerToast(t('There was an issue exporting the database'));
+        addDangerToast(
+          t('There was an issue exporting the %s', databaseLabelLower()),
+        );
       }
     },
     [addDangerToast, setPreparingExport],
@@ -777,7 +784,7 @@ function DatabaseList({
                 >
                   <Tooltip
                     id="delete-action-tooltip"
-                    title={t('Delete database')}
+                    title={t('Delete %s', databaseLabelLower())}
                     placement="bottom"
                   >
                     <Icons.DeleteOutlined iconSize="l" />
@@ -835,7 +842,7 @@ function DatabaseList({
         operator: FilterOperator.Equals,
         unfilteredLabel: t('All'),
         selects: [
-          { label: t('Database'), value: 'database' },
+          { label: databaseLabel(), value: 'database' },
           { label: t('Semantic Layer'), value: 'semantic_layer' },
         ],
       });
@@ -887,7 +894,8 @@ function DatabaseList({
             'changed_by',
             createErrorHandler(errMsg =>
               t(
-                'An error occurred while fetching dataset datasource values: %s',
+                'An error occurred while fetching %s values: %s',
+                databaseLabelLower(),
                 errMsg,
               ),
             ),
@@ -990,7 +998,7 @@ function DatabaseList({
           description={
             <>
               <p>
-                {t('The database')}{' '}
+                {t('The %s', databaseLabelLower())}{' '}
                 <b>{databaseCurrentlyDeleting.database_name}</b>{' '}
                 {t(
                   'is linked to %s charts that appear on %s dashboards and users have %s SQL Lab tabs using this database open. Are you sure you want to continue? Deleting the database will break those objects.',
@@ -1098,7 +1106,7 @@ function DatabaseList({
           title={
             <ModalTitleWithIcon
               icon={<Icons.DeleteOutlined />}
-              title={t('Delete Database?')}
+              title={t('Delete %s?', databaseLabel())}
             />
           }
         />
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index dcb9f4d11cd..3d22d5b4a9b 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -80,6 +80,13 @@ import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
 import type DatasetType from 'src/types/Dataset';
 import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
 import AddSemanticViewModal from 'src/features/semanticViews/AddSemanticViewModal';
+import {
+  datasetLabel,
+  datasetLabelLower,
+  datasetsLabel,
+  datasetsLabelLower,
+  databaseLabel,
+} from 'src/utils/semanticLayerLabels';
 import { useSelector } from 'react-redux';
 import { QueryObjectColumns } from 'src/views/CRUD/types';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -175,7 +182,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     state: { bulkSelectEnabled },
     hasPerm,
     toggleBulkSelect,
-  } = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
+  } = useListViewResource<Dataset>(
+    'dataset',
+    datasetLabelLower(),
+    addDangerToast,
+  );
 
   // Combined endpoint state
   const [datasets, setDatasets] = useState<Dataset[]>([]);
@@ -236,7 +247,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           setDatasetCount(json.count);
         })
         .catch(() => {
-          addDangerToast(t('An error occurred while fetching datasets'));
+          addDangerToast(
+            t('An error occurred while fetching %s', datasetsLabelLower()),
+          );
         })
         .finally(() => {
           setLoading(false);
@@ -322,7 +335,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
   const handleDatasetImport = () => {
     showImportModal(false);
     refreshData();
-    addSuccessToast(t('Dataset imported'));
+    addSuccessToast(t('%s imported', datasetLabel()));
   };
 
   const canEdit = hasPerm('can_write');
@@ -360,7 +373,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         })
         .catch(() => {
           addDangerToast(
-            t('An error occurred while fetching dataset related data'),
+            t(
+              'An error occurred while fetching %s related data',
+              datasetLabelLower(),
+            ),
           );
         });
     },
@@ -404,7 +420,12 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         });
       } catch {
         setPreparingExport(false);
-        addDangerToast(t('There was an issue exporting the selected datasets'));
+        addDangerToast(
+          t(
+            'There was an issue exporting the selected %s',
+            datasetsLabelLower(),
+          ),
+        );
       }
     },
     [addDangerToast, setPreparingExport],
@@ -517,7 +538,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
             original: { database },
           },
         }: CellProps<Dataset>) => database?.database_name || '-',
-        Header: t('Database'),
+        Header: databaseLabel(),
         accessor: 'database.database_name',
         size: 'xl',
         id: 'database.database_name',
@@ -752,7 +773,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
               operator: FilterOperator.Equals,
               unfilteredLabel: t('All'),
               selects: [
-                { label: t('Database'), value: 'database' },
+                { label: databaseLabel(), value: 'database' },
                 { label: t('Semantic Layer'), value: 'semantic_layer' },
               ],
             },
@@ -802,7 +823,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
             },
           ]),
       {
-        Header: t('Database'),
+        Header: databaseLabel(),
         key: 'database',
         id: 'database',
         input: 'select',
@@ -812,7 +833,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           'dataset',
           'database',
           createErrorHandler(errMsg =>
-            t('An error occurred while fetching datasets: %s', errMsg),
+            t(
+              'An error occurred while fetching %s: %s',
+              datasetsLabelLower(),
+              errMsg,
+            ),
           ),
         ),
         paginate: true,
@@ -846,7 +871,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           'dataset',
           createErrorHandler(errMsg =>
             t(
-              'An error occurred while fetching dataset owner values: %s',
+              'An error occurred while fetching %s owner values: %s',
+              datasetLabelLower(),
               errMsg,
             ),
           ),
@@ -881,7 +907,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           'changed_by',
           createErrorHandler(errMsg =>
             t(
-              'An error occurred while fetching dataset datasource values: %s',
+              'An error occurred while fetching %s values: %s',
+              datasetLabelLower(),
               errMsg,
             ),
           ),
@@ -896,7 +923,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
 
   const menuData: SubMenuProps = {
     activeChild: 'Datasets',
-    name: t('Datasets'),
+    name: datasetsLabel(),
   };
 
   const buttonArr: Array<ButtonProps> = [];
@@ -906,7 +933,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
       name: (
         <Tooltip
           id="import-tooltip"
-          title={t('Import datasets')}
+          title={t('Import %s', datasetsLabelLower())}
           placement="bottomRight"
         >
           <Icons.DownloadOutlined
@@ -943,7 +970,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
               items: [
                 {
                   key: 'dataset',
-                  label: t('Dataset'),
+                  label: datasetLabel(),
                   onClick: () => history.push('/dataset/add/'),
                 },
                 {
@@ -975,7 +1002,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     } else {
       buttonArr.push({
         icon: <Icons.PlusOutlined iconSize="m" />,
-        name: t('Dataset'),
+        name: datasetLabel(),
         onClick: () => {
           history.push('/dataset/add/');
         },
@@ -1053,7 +1080,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         addSuccessToast(t('Deleted %s item(s)', datasetsToDelete.length));
       } else {
         addDangerToast(
-          t('There was an issue deleting the selected items'),
+          t('There was an issue deleting the selected %s', datasetsLabelLower()),
         );
       }
     });
@@ -1061,7 +1088,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
 
   const handleDatasetDuplicate = (newDatasetName: string) => {
     if (datasetCurrentlyDuplicating === null) {
-      addDangerToast(t('There was an issue duplicating the dataset.'));
+      addDangerToast(
+        t('There was an issue duplicating the %s.', datasetLabelLower()),
+      );
     }
 
     SupersetClient.post({
@@ -1077,7 +1106,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
       },
       createErrorHandler(errMsg =>
         addDangerToast(
-          t('There was an issue duplicating the selected datasets: %s', errMsg),
+          t(
+            'There was an issue duplicating the selected %s: %s',
+            datasetsLabelLower(),
+            errMsg,
+          ),
         ),
       ),
     );
@@ -1091,7 +1124,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           description={
             <>
               <p>
-                {t('The dataset')}
+                {t('The %s', datasetLabelLower())}
                 <b> {datasetCurrentlyDeleting.table_name} </b>
                 {t(
                   'is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the dataset will break those objects.',
@@ -1197,7 +1230,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
           }}
           onHide={closeDatasetDeleteModal}
           open
-          title={t('Delete Dataset?')}
+          title={t('Delete %s?', datasetLabel())}
         />
       )}
       {svCurrentlyDeleting && (
@@ -1243,7 +1276,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
       <ConfirmStatusChange
         title={t('Please confirm')}
         description={t(
-          'Are you sure you want to delete the selected datasets?',
+          'Are you sure you want to delete the selected %s?',
+          datasetsLabelLower(),
         )}
         onConfirm={handleBulkDatasetDelete}
       >
@@ -1326,7 +1360,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
 
       <ImportModelsModal
         resourceName="dataset"
-        resourceLabel={t('dataset')}
+        resourceLabel={datasetLabelLower()}
         passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE}
         confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE}
         addDangerToast={addDangerToast}
diff --git a/superset-frontend/src/utils/semanticLayerLabels.ts b/superset-frontend/src/utils/semanticLayerLabels.ts
new file mode 100644
index 00000000000..e5ad52b3006
--- /dev/null
+++ b/superset-frontend/src/utils/semanticLayerLabels.ts
@@ -0,0 +1,63 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+
+/**
+ * When the SEMANTIC_LAYERS feature flag is enabled the UI broadens
+ * "dataset" → "datasource" and "database" → "data connection" so
+ * that semantic views and semantic layers feel like first-class
+ * citizens alongside traditional datasets and database connections.
+ */
+function sl<T>(legacy: T, semantic: T): T {
+  return isFeatureEnabled(FeatureFlag.SemanticLayers) ? semantic : legacy;
+}
+
+// ---------------------------------------------------------------------------
+// "dataset" family
+// ---------------------------------------------------------------------------
+
+/** Capitalized singular: "Dataset" / "Datasource" */
+export const datasetLabel = () => sl(t('Dataset'), t('Datasource'));
+
+/** Lower-case singular: "dataset" / "datasource" */
+export const datasetLabelLower = () => sl(t('dataset'), t('datasource'));
+
+/** Capitalized plural: "Datasets" / "Datasources" */
+export const datasetsLabel = () => sl(t('Datasets'), t('Datasources'));
+
+/** Lower-case plural: "datasets" / "datasources" */
+export const datasetsLabelLower = () => sl(t('datasets'), t('datasources'));
+
+// ---------------------------------------------------------------------------
+// "database" family
+// ---------------------------------------------------------------------------
+
+/** Capitalized singular: "Database" / "Data connection" */
+export const databaseLabel = () => sl(t('Database'), t('Data connection'));
+
+/** Lower-case singular: "database" / "data connection" */
+export const databaseLabelLower = () => sl(t('database'), t('data connection'));
+
+/** Capitalized plural: "Databases" / "Data connections" */
+export const databasesLabel = () => sl(t('Databases'), t('Data connections'));
+
+/** Lower-case plural: "databases" / "data connections" */
+export const databasesLabelLower = () =>
+  sl(t('databases'), t('data connections'));


(superset) 14/25: More fixes

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 47973bd789231a365f04dbc8eaf8bddc16e64b4a
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Mar 12 10:50:31 2026 -0400

    More fixes
---
 superset-frontend/src/components/Chart/Chart.tsx   |  3 ++-
 .../Chart/DrillDetail/DrillDetailPane.tsx          |  3 ++-
 .../src/components/DatabaseSelector/index.tsx      | 24 ++++++++++++++++++----
 .../Datasource/ChangeDatasourceModal/index.tsx     | 11 +++++++---
 .../Datasource/DatasourceModal/index.tsx           |  7 ++++---
 .../DatasourceEditor/DatasourceEditor.tsx          |  3 ++-
 .../ErrorMessage/DatasetNotFoundErrorMessage.tsx   |  3 ++-
 .../src/components/ImportModal/index.tsx           |  3 ++-
 .../components/AddSliceCard/AddSliceCard.tsx       |  3 ++-
 .../src/dashboard/components/SliceAdder.tsx        |  3 ++-
 .../FilterBar/FilterControls/GroupByFilterCard.tsx | 11 ++++++++--
 .../FiltersConfigForm/DatasetSelect.tsx            | 11 +++++++---
 .../FiltersConfigForm/FiltersConfigForm.tsx        |  7 ++++---
 .../DataTablesPane/components/SamplesPane.tsx      |  6 +++++-
 .../explore/components/DatasourcePanel/index.tsx   |  3 ++-
 .../explore/components/ExploreChartPanel/index.tsx |  5 +++--
 .../src/explore/components/SaveModal.tsx           |  8 +++++---
 .../controls/DatasourceControl/index.tsx           | 20 ++++++++++--------
 .../DndColumnSelectControl/ColumnSelectPopover.tsx |  7 ++++---
 .../DndAdhocFilterOption.tsx                       |  6 +++++-
 .../DndColumnMetricSelect.tsx                      |  6 +++++-
 .../DndColumnSelectControl/DndColumnSelect.tsx     |  6 +++++-
 .../DndColumnSelectControl/DndMetricSelect.tsx     | 11 ++++++++--
 .../MetricControl/AdhocMetricEditPopover/index.tsx |  3 ++-
 .../components/controls/ViewQueryModalFooter.tsx   |  3 ++-
 superset-frontend/src/explore/controls.tsx         |  3 ++-
 .../src/pages/ChartCreation/index.tsx              | 22 ++++++++++++++------
 superset-frontend/src/pages/ChartList/index.tsx    |  5 +++--
 28 files changed, 147 insertions(+), 59 deletions(-)

diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx
index a7e38aa61a4..c20ca163c95 100644
--- a/superset-frontend/src/components/Chart/Chart.tsx
+++ b/superset-frontend/src/components/Chart/Chart.tsx
@@ -46,6 +46,7 @@ import { Dispatch } from 'redux';
 import ChartRenderer from './ChartRenderer';
 import { ChartErrorMessage } from './ChartErrorMessage';
 import { getChartRequiredFieldsMissingMessage } from '../../utils/getChartRequiredFieldsMissingMessage';
+import { databaseLabelLower } from 'src/utils/semanticLayerLabels';
 
 export type ChartErrorType = Partial<ClientErrorObject>;
 export interface ChartProps {
@@ -312,7 +313,7 @@ class Chart extends PureComponent<ChartProps, {}> {
   renderSpinner(databaseName: string | undefined) {
     const message = databaseName
       ? t('Waiting on %s', databaseName)
-      : t('Waiting on database...');
+      : t('Waiting on %s...', databaseLabelLower());
 
     return (
       <LoadingDiv>
diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
index 747c17d80bf..cf44a7f1314 100644
--- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
+++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx
@@ -53,6 +53,7 @@ import { Dataset } from '../types';
 import TableControls from './DrillDetailTableControls';
 import { getDrillPayload } from './utils';
 import { ResultsPage } from './types';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const PAGE_SIZE = 50;
 
@@ -303,7 +304,7 @@ export default function DrillDetailPane({
     tableContent = <Loading />;
   } else if (resultsPage?.total === 0) {
     // Render empty state if no results are returned for page
-    const title = t('No rows were returned for this dataset');
+    const title = t('No rows were returned for this %s', datasetLabelLower());
     tableContent = <EmptyState image="document.svg" title={title} />;
   } else {
     // Render table if at least one page has successfully loaded
diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx b/superset-frontend/src/components/DatabaseSelector/index.tsx
index ee5f39b9979..361f63dc75a 100644
--- a/superset-frontend/src/components/DatabaseSelector/index.tsx
+++ b/superset-frontend/src/components/DatabaseSelector/index.tsx
@@ -52,6 +52,10 @@ import type {
   DatabaseObject,
 } from './types';
 import { StyledFormLabel } from './styles';
+import {
+  databaseLabel,
+  databasesLabelLower,
+} from 'src/utils/semanticLayerLabels';
 
 const DatabaseSelectorWrapper = styled.div<{ horizontal?: boolean }>`
   ${({ theme, horizontal }) =>
@@ -431,7 +435,11 @@ export function DatabaseSelector({
   function renderDatabaseSelect() {
     if (sqlLabMode) {
       return renderSelectRow(
-        t('Select database or type to search databases'),
+        t(
+          'Select %s or type to search %s',
+          databaseLabel().toLowerCase(),
+          databasesLabelLower(),
+        ),
         null,
         null,
         {
@@ -448,16 +456,24 @@ export function DatabaseSelector({
     return (
       <div>
         {renderSelectRow(
-          t('Database'),
+          databaseLabel(),
           <AsyncSelect
-            ariaLabel={t('Select database or type to search databases')}
+            ariaLabel={t(
+              'Select %s or type to search %s',
+              databaseLabel().toLowerCase(),
+              databasesLabelLower(),
+            )}
             optionFilterProps={['database_name', 'value']}
             data-test="select-database"
             lazyLoading={false}
             notFoundContent={emptyState}
             onChange={changeDatabase}
             value={currentDb}
-            placeholder={t('Select database or type to search databases')}
+            placeholder={t(
+              'Select %s or type to search %s',
+              databaseLabel().toLowerCase(),
+              databasesLabelLower(),
+            )}
             disabled={!isDatabaseSelectEnabled || readOnly}
             options={loadDatabases}
             sortComparator={sortComparator}
diff --git a/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx
index 3b8f3531039..c9d67bb6c4e 100644
--- a/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx
+++ b/superset-frontend/src/components/Datasource/ChangeDatasourceModal/index.tsx
@@ -53,6 +53,7 @@ import {
 import withToasts from 'src/components/MessageToasts/withToasts';
 import { InputRef } from 'antd';
 import type { Datasource, ChangeDatasourceModalProps } from '../types';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const CONFIRM_WARNING_MESSAGE = t(
   'Warning! Changing the dataset may break the chart if the metadata does not exist.',
@@ -109,7 +110,11 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
   const {
     state: { loading, resourceCollection, resourceCount },
     fetchData,
-  } = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
+  } = useListViewResource<Dataset>(
+    'dataset',
+    datasetLabelLower(),
+    addDangerToast,
+  );
 
   const selectDatasource = useCallback((datasource: Datasource) => {
     setConfirmChange(true);
@@ -187,7 +192,7 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
         );
       });
     onHide();
-    addSuccessToast(t('Successfully changed dataset!'));
+    addSuccessToast(t('Successfully changed %s!', datasetLabelLower()));
   };
 
   const handlerCancelConfirm = () => {
@@ -253,7 +258,7 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
       onHide={onHide}
       responsive
       name="Swap dataset"
-      title={t('Swap dataset')}
+      title={t('Swap %s', datasetLabelLower())}
       width={confirmChange ? '432px' : ''}
       height={confirmChange ? 'auto' : '540px'}
       hideFooter={!confirmChange}
diff --git a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
index d3e34b76484..ef4033331fc 100644
--- a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
+++ b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx
@@ -40,6 +40,7 @@ import withToasts from 'src/components/MessageToasts/withToasts';
 import { ErrorMessageWithStackTrace } from 'src/components';
 import type { DatasetObject } from 'src/features/datasets/types';
 import type { DatasourceModalProps } from '../types';
+import { datasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const DatasourceEditor = AsyncEsmComponent(
   () => import('../components/DatasourceEditor'),
@@ -195,7 +196,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
         endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
       });
 
-      addSuccessToast(t('The dataset has been saved'));
+      addSuccessToast(t('The %s has been saved', datasetLabelLower()));
       // eslint-disable-next-line no-param-reassign
       json.result.type = 'table';
       onDatasourceSave({
@@ -216,7 +217,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
         errorText = error.error;
       }
       modal.error({
-        title: t('Error saving dataset'),
+        title: t('Error saving %s', datasetLabelLower()),
         okButtonProps: { danger: true, className: 'btn-danger' },
         content: (
           <ErrorMessageWithStackTrace
@@ -326,7 +327,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
             `}
             data-test="edit-alt"
           />
-          {t('Edit Dataset ')}
+          {t('Edit %s ', datasetLabel())}
           <strong>{currentDatasource.table_name}</strong>
         </span>
       }
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index 6150927ca6a..ba8710bf2d2 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -101,6 +101,7 @@ import {
 } from '../../FoldersEditor/treeUtils';
 import FoldersEditor from '../../FoldersEditor';
 import { DatasourceFolder } from 'src/explore/components/DatasourcePanel/types';
+import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 const extensionsRegistry = getExtensionsRegistry();
 
@@ -1906,7 +1907,7 @@ class DatasourceEditor extends PureComponent<
                             onChange={table => {
                               this.onDatasourcePropChange('table_name', table);
                             }}
-                            placeholder={t('Dataset name')}
+                            placeholder={t('%s name', datasetLabel())}
                             disabled={!this.state.isEditMode}
                           />
                         }
diff --git a/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx b/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx
index e74da4d0649..26cd6e4f8cd 100644
--- a/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx
+++ b/superset-frontend/src/components/ErrorMessage/DatasetNotFoundErrorMessage.tsx
@@ -20,6 +20,7 @@ import { t } from '@apache-superset/core/translation';
 
 import type { ErrorMessageComponentProps } from './types';
 import { ErrorAlert } from './ErrorAlert';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 export function DatasetNotFoundErrorMessage({
   error,
@@ -29,7 +30,7 @@ export function DatasetNotFoundErrorMessage({
   const { level, message } = error;
   return (
     <ErrorAlert
-      errorType={t('Missing dataset')}
+      errorType={t('Missing %s', datasetLabelLower())}
       message={subtitle}
       description={message}
       type={level}
diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx
index b046abf8880..a3097803b14 100644
--- a/superset-frontend/src/components/ImportModal/index.tsx
+++ b/superset-frontend/src/components/ImportModal/index.tsx
@@ -29,6 +29,7 @@ import { Button, Input, Modal } from '@superset-ui/core/components';
 import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
 import { ImportErrorAlert } from './ImportErrorAlert';
 import type { ImportModelsModalProps } from './types';
+import { databaseLabel } from 'src/utils/semanticLayerLabels';
 
 const HelperMessage = styled.div`
   display: block;
@@ -247,7 +248,7 @@ export const ImportModal: FunctionComponent<ImportModelsModalProps> = ({
 
     return (
       <>
-        <h5>{t('Database passwords')}</h5>
+        <h5>{t('%s passwords', databaseLabel())}</h5>
         <HelperMessage>{passwordsNeededMessage}</HelperMessage>
         {files.map(fileName => (
           <>
diff --git a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx
index 56229cc2bc3..8d0c75c3a96 100644
--- a/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx
+++ b/superset-frontend/src/dashboard/components/AddSliceCard/AddSliceCard.tsx
@@ -36,6 +36,7 @@ import { Tooltip, ImageLoader } from '@superset-ui/core/components';
 import { GenericLink, usePluginContext } from 'src/components';
 import { assetUrl } from 'src/utils/assetUrl';
 import { Theme } from '@emotion/react';
+import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 const FALLBACK_THUMBNAIL_URL = assetUrl(
   '/static/assets/images/chart-card-fallback.svg',
@@ -283,7 +284,7 @@ const AddSliceCard: FC<{
             >
               <MetadataItem label={t('Viz type')} value={vizName} />
               <MetadataItem
-                label={t('Dataset')}
+                label={datasetLabel()}
                 value={
                   datasourceUrl ? (
                     <GenericLink to={datasourceUrl}>
diff --git a/superset-frontend/src/dashboard/components/SliceAdder.tsx b/superset-frontend/src/dashboard/components/SliceAdder.tsx
index 8ad34e3c065..49fd3ce9ff9 100644
--- a/superset-frontend/src/dashboard/components/SliceAdder.tsx
+++ b/superset-frontend/src/dashboard/components/SliceAdder.tsx
@@ -55,6 +55,7 @@ import type { ConnectDragSource } from 'react-dnd';
 import AddSliceCard from './AddSliceCard';
 import AddSliceDragPreview from './dnd/AddSliceDragPreview';
 import { DragDroppable } from './dnd/DragDroppable';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 export type SliceAdderProps = {
   theme: Theme;
@@ -88,7 +89,7 @@ const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
 const KEYS_TO_SORT = {
   slice_name: t('name'),
   viz_type: t('viz type'),
-  datasource_name: t('dataset'),
+  datasource_name: datasetLabelLower(),
   changed_on: t('recent'),
 };
 
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
index b079f133af9..2c609088340 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/GroupByFilterCard.tsx
@@ -51,6 +51,7 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
 import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
 import { dispatchChartCustomizationHoverAction } from './utils';
 import { mergeExtraFormData } from '../../utils';
+import { datasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 interface ColumnApiResponse {
   column_name?: string;
@@ -262,7 +263,7 @@ const GroupByFilterCardContent: FC<{
       </Row>
 
       <Row>
-        <RowLabel>{t('Dataset')}</RowLabel>
+        <RowLabel>{datasetLabel()}</RowLabel>
         <RowValue>
           {typeof datasetLabel === 'string' ? datasetLabel : 'Dataset'}
         </RowValue>
@@ -475,7 +476,13 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
       } catch (error) {
         setColumnOptions([]);
         dispatch(
-          addDangerToast(t('Failed to load columns for dataset %s', datasetId)),
+          addDangerToast(
+            t(
+              'Failed to load columns for %s %s',
+              datasetLabelLower(),
+              datasetId,
+            ),
+          ),
         );
       } finally {
         setLoading(false);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx
index f48e6dc039f..f77c8e84cbd 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx
@@ -30,6 +30,11 @@ import {
   Dataset,
   DatasetSelectLabel,
 } from 'src/features/datasets/DatasetSelectLabel';
+import {
+  datasetLabel,
+  datasetLabelLower,
+  datasetsLabelLower,
+} from 'src/utils/semanticLayerLabels';
 
 interface DatasetSelectProps {
   onChange: (value: { label: string | ReactNode; value: number }) => void;
@@ -101,13 +106,13 @@ const DatasetSelect = ({
 
   return (
     <AsyncSelect
-      ariaLabel={t('Dataset')}
+      ariaLabel={datasetLabel()}
       value={value}
       options={loadDatasetOptionsCallback}
       onChange={onChange}
       optionFilterProps={['table_name']}
-      notFoundContent={t('No compatible datasets found')}
-      placeholder={t('Select a dataset')}
+      notFoundContent={t('No compatible %s found', datasetsLabelLower())}
+      placeholder={t('Select a %s', datasetLabelLower())}
     />
   );
 };
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
index ab6a9f22e69..ddf41f39fd3 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
@@ -115,6 +115,7 @@ import {
   INPUT_WIDTH,
 } from './constants';
 import DependencyList from './DependencyList';
+import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 const FORM_ITEM_WIDTH = 260;
 
@@ -972,7 +973,7 @@ const FiltersConfigForm = (
                       <StyledFormItem
                         expanded={expanded}
                         name={['filters', filterId, 'dataset']}
-                        label={<StyledLabel>{t('Dataset')}</StyledLabel>}
+                        label={<StyledLabel>{datasetLabel()}</StyledLabel>}
                         initialValue={
                           datasetDetails
                             ? {
@@ -992,7 +993,7 @@ const FiltersConfigForm = (
                         rules={[
                           {
                             required: !isRemoved,
-                            message: t('Dataset is required'),
+                            message: t('%s is required', datasetLabel()),
                           },
                         ]}
                         {...getFiltersConfigModalTestId('datasource-input')}
@@ -1018,7 +1019,7 @@ const FiltersConfigForm = (
                     ) : (
                       <StyledFormItem
                         expanded={expanded}
-                        label={<StyledLabel>{t('Dataset')}</StyledLabel>}
+                        label={<StyledLabel>{datasetLabel()}</StyledLabel>}
                       >
                         <Loading position="inline-centered" />
                       </StyledFormItem>
diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
index 1c58bd9ae69..d79d3d537de 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
@@ -19,6 +19,7 @@
 import { useState, useEffect, useMemo, useCallback } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { ensureIsArray } from '@superset-ui/core';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 import { styled } from '@apache-superset/core/theme';
 import {
   TableView,
@@ -135,7 +136,10 @@ export const SamplesPane = ({
   }
 
   if (data.length === 0) {
-    const title = t('No samples were returned for this dataset');
+    const title = t(
+      'No samples were returned for this %s',
+      datasetLabelLower(),
+    );
     return <EmptyState image="document.svg" title={title} />;
   }
 
diff --git a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
index 74718ae3751..8a340c55f25 100644
--- a/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
+++ b/superset-frontend/src/explore/components/DatasourcePanel/index.tsx
@@ -22,6 +22,7 @@ import { DatasourceType, Metric, QueryFormData } from '@superset-ui/core';
 import { Alert } from '@apache-superset/core/components';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
 
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 import { ControlConfig } from '@superset-ui/chart-controls';
 import AutoSizer from 'react-virtualized-auto-sizer';
 
@@ -291,7 +292,7 @@ export default function DataSourcePanel({
                       onClick={() => setShowSaveDatasetModal(true)}
                       className="add-dataset-alert-description"
                     >
-                      {t('Create a dataset')}
+                      {t('Create a %s', datasetLabelLower())}
                     </span>
                     {t(' to edit or add columns and metrics.')}
                   </>
diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
index c692e65bfd1..fbfce00da15 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
@@ -31,6 +31,7 @@ import {
   getExtensionsRegistry,
 } from '@superset-ui/core';
 import { Alert } from '@apache-superset/core/components';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 import { css, styled, useTheme } from '@apache-superset/core/theme';
 import ChartContainer from 'src/components/Chart/ChartContainer';
 import {
@@ -325,7 +326,7 @@ const ExploreChartPanel = ({
       >
         {vizTypeNeedsDataset && (
           <Alert
-            message={t('Chart type requires a dataset')}
+            message={t('Chart type requires a %s', datasetLabelLower())}
             type="error"
             css={theme => css`
               margin: 0 0 ${theme.sizeUnit * 4}px 0;
@@ -341,7 +342,7 @@ const ExploreChartPanel = ({
                   onClick={() => setShowDatasetModal(true)}
                   css={{ textDecoration: 'underline' }}
                 >
-                  {t('Create a dataset')}
+                  {t('Create a %s', datasetLabelLower())}
                 </span>
                 {t(' to visualize your data.')}
               </>
diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx
index a78b4ce7dc1..2d05d1817e0 100644
--- a/superset-frontend/src/explore/components/SaveModal.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.tsx
@@ -59,6 +59,7 @@ import {
 import { Dashboard } from 'src/types/Dashboard';
 import { TabNode, TabTreeNode } from '../types';
 import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants';
+import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 // Session storage key for recent dashboard
 const SK_DASHBOARD_ID = 'save_chart_recent_dashboard';
@@ -635,11 +636,12 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
           <FormItem
             label={
               <Flex align="center" gap={this.props.theme.sizeUnit}>
-                {t('Dataset Name')}
+                {t('%s Name', datasetLabel())}
                 <InfoTooltip
                   data-test="info-tooltip-icon"
                   tooltip={t(
-                    'A reusable dataset will be saved with your chart.',
+                    'A reusable %s will be saved with your chart.',
+                    datasetLabel().toLowerCase(),
                   )}
                   placement="right"
                 />
@@ -650,7 +652,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
             <Input
               name="dataset_name"
               type="text"
-              placeholder={t('Dataset Name')}
+              placeholder={t('%s Name', datasetLabel())}
               value={this.state.datasetName}
               onChange={this.handleDatasetNameChange}
               data-test="new-dataset-name"
diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
index 76136b8b483..512795b2d3e 100644
--- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
+++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx
@@ -45,6 +45,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
 import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
 import { URL_PARAMS } from 'src/constants';
 import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 import {
   userHasPermission,
   isUserAdmin,
@@ -375,7 +376,7 @@ class DatasourceControl extends PureComponent<
 
     const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access');
 
-    const editText = t('Edit dataset');
+    const editText = t('Edit %s', datasetLabelLower());
     const requestedQuery = {
       datasourceKey: `${datasource.id}__${datasource.type}`,
       sql: datasource.sql,
@@ -387,7 +388,9 @@ class DatasourceControl extends PureComponent<
         label: !allowEdit ? (
           <Tooltip
             title={t(
-              'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.',
+              'You must be a %s owner in order to edit. Please reach out to a %s owner to request modifications or edit access.',
+              datasetLabelLower(),
+              datasetLabelLower(),
             )}
           >
             {editText}
@@ -402,7 +405,7 @@ class DatasourceControl extends PureComponent<
 
     defaultDatasourceMenuItems.push({
       key: CHANGE_DATASET,
-      label: t('Swap dataset'),
+      label: t('Swap %s', datasetLabelLower()),
     });
 
     if (!isMissingDatasource && canAccessSqlLab) {
@@ -481,7 +484,7 @@ class DatasourceControl extends PureComponent<
 
     queryDatasourceMenuItems.push({
       key: SAVE_AS_DATASET,
-      label: <span>{t('Save as dataset')}</span>,
+      label: <span>{t('Save as %s', datasetLabelLower())}</span>,
     });
 
     const queryDatasourceMenu = (
@@ -495,7 +498,7 @@ class DatasourceControl extends PureComponent<
 
     const titleText =
       isMissingDatasource && !datasource.name
-        ? t('Missing dataset')
+        ? t('Missing %s', datasetLabelLower())
         : getDatasourceTitle(datasource);
 
     const tooltip = titleText;
@@ -561,14 +564,15 @@ class DatasourceControl extends PureComponent<
             ) : (
               <ErrorAlert
                 type="warning"
-                message={t('Missing dataset')}
+                message={t('Missing %s', datasetLabelLower())}
                 descriptionPre={false}
                 descriptionDetailsCollapsed={false}
                 descriptionDetails={
                   <>
                     <p>
                       {t(
-                        'The dataset linked to this chart may have been deleted.',
+                        'The %s linked to this chart may have been deleted.',
+                        datasetLabelLower(),
                       )}
                     </p>
                     <p>
@@ -578,7 +582,7 @@ class DatasourceControl extends PureComponent<
                           this.handleMenuItemClick({ key: CHANGE_DATASET })
                         }
                       >
-                        {t('Swap dataset')}
+                        {t('Swap %s', datasetLabelLower())}
                       </Button>
                     </p>
                   </>
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx
index f61fafe8bcb..bd8ab88c700 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/ColumnSelectPopover.tsx
@@ -57,6 +57,7 @@ import {
 } from 'src/explore/constants';
 import { ExplorePageState } from 'src/explore/types';
 import useResizeButton from './useResizeButton';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const TABS_KEYS = {
   SAVED: 'saved',
@@ -472,7 +473,7 @@ const ColumnSelectPopover = ({
                                   tabIndex={0}
                                   onClick={setDatasetAndClose}
                                 >
-                                  {t('Create a dataset')}
+                                  {t('Create a %s', datasetLabelLower())}
                                 </span>{' '}
                                 {t(' to mark a column as a time column')}
                               </>
@@ -483,7 +484,7 @@ const ColumnSelectPopover = ({
                                   tabIndex={0}
                                   onClick={setDatasetAndClose}
                                 >
-                                  {t('Create a dataset')}
+                                  {t('Create a %s', datasetLabelLower())}
                                 </span>{' '}
                                 {t(' to add calculated columns')}
                               </>
@@ -517,7 +518,7 @@ const ColumnSelectPopover = ({
                             tabIndex={0}
                             onClick={setDatasetAndClose}
                           >
-                            {t('Create a dataset')}
+                            {t('Create a %s', datasetLabelLower())}
                           </span>{' '}
                           {t(' to mark a column as a time column')}
                         </>
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx
index 857b098003b..96024e6fa0d 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndAdhocFilterOption.tsx
@@ -23,6 +23,7 @@ import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilt
 import { OptionSortType } from 'src/explore/types';
 import { useGetTimeRangeLabel } from 'src/explore/components/controls/FilterControl/utils';
 import OptionWrapper from './OptionWrapper';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 export interface DndAdhocFilterOptionProps {
   adhocFilter: AdhocFilter;
@@ -68,7 +69,10 @@ export default function DndAdhocFilterOption({
         isExtra={adhocFilter.isExtra}
         datasourceWarningMessage={
           adhocFilter.datasourceWarning
-            ? t('This filter might be incompatible with current dataset')
+            ? t(
+                'This filter might be incompatible with current %s',
+                datasetLabelLower(),
+              )
             : undefined
         }
       />
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
index 126d699ddff..1f81bbb9a85 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnMetricSelect.tsx
@@ -38,6 +38,7 @@ import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetr
 import MetricDefinitionValue from 'src/explore/components/controls/MetricControl/MetricDefinitionValue';
 import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
 import { DndControlProps } from './types';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const AGGREGATED_DECK_GL_CHART_TYPES = [
   'deck_screengrid',
@@ -326,7 +327,10 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
             typeof item === 'object' &&
             'error_text' in item &&
             item.error_text)
-            ? t('This metric might be incompatible with current dataset')
+            ? t(
+                'This metric might be incompatible with current %s',
+                datasetLabelLower(),
+              )
             : undefined;
 
         return (
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
index 6e474e552cf..2987d41e3be 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndColumnSelect.tsx
@@ -29,6 +29,7 @@ import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/t
 import { DndItemType } from 'src/explore/components/DndItemType';
 import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
 import { DndControlProps } from './types';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 export type DndColumnSelectProps = DndControlProps<QueryFormColumn> & {
   options: ColumnMeta[];
@@ -103,7 +104,10 @@ function DndColumnSelect(props: DndColumnSelectProps) {
       optionSelector.values.map((column, idx) => {
         const datasourceWarningMessage =
           isAdhocColumn(column) && column.datasourceWarning
-            ? t('This column might be incompatible with current dataset')
+            ? t(
+                'This column might be incompatible with current %s',
+                datasetLabelLower(),
+              )
             : undefined;
         const withCaret = isAdhocColumn(column) || !column.error_text;
 
diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
index 0dd1074acec..de5608c03e2 100644
--- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
+++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.tsx
@@ -41,6 +41,7 @@ import { DndItemType } from 'src/explore/components/DndItemType';
 import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
 import { savedMetricType } from 'src/explore/components/controls/MetricControl/types';
 import { AGGREGATES } from 'src/explore/constants';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 const EMPTY_OBJECT = {};
 const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
@@ -77,7 +78,10 @@ const coerceMetrics = (
     ) {
       return {
         metric_name: metric,
-        error_text: t('This metric might be incompatible with current dataset'),
+        error_text: t(
+          'This metric might be incompatible with current %s',
+          datasetLabelLower(),
+        ),
         uuid: nanoid(),
       };
     }
@@ -296,7 +300,10 @@ const DndMetricSelect = (props: any) => {
         multi={multi}
         datasourceWarningMessage={
           option instanceof AdhocMetric && option.datasourceWarning
-            ? t('This metric might be incompatible with current dataset')
+            ? t(
+                'This metric might be incompatible with current %s',
+                datasetLabelLower(),
+              )
             : undefined
         }
       />
diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
index 031248df0a0..000ac461e9c 100644
--- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
+++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx
@@ -49,6 +49,7 @@ import {
 import { getColumnKeywords } from 'src/explore/controlUtils/getColumnKeywords';
 import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
 import type { RefObject } from 'react';
+import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 interface ColumnType {
   column_name: string;
@@ -475,7 +476,7 @@ export default class AdhocMetricEditPopover extends PureComponent<
                             this.props.onClose();
                           }}
                         >
-                          {t('Create a dataset')}
+                          {t('Create a %s', datasetLabelLower())}
                         </span>
                         {t(' to add metrics')}
                       </>
diff --git a/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx b/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx
index fc8c7c948b2..ee14b70436f 100644
--- a/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx
+++ b/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx
@@ -22,6 +22,7 @@ import { t } from '@apache-superset/core/translation';
 import { SupersetClient } from '@superset-ui/core';
 import { Button } from '@superset-ui/core/components';
 import { useHistory } from 'react-router-dom';
+import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 interface SimpleDataSource {
   id: string;
@@ -36,7 +37,7 @@ interface ViewQueryModalFooterProps {
 }
 
 const CLOSE = t('Close');
-const SAVE_AS_DATASET = t('Save as Dataset');
+const SAVE_AS_DATASET = t('Save as %s', datasetLabel());
 const OPEN_IN_SQL_LAB = t('Open in SQL Lab');
 
 const ViewQueryModalFooter: FC<ViewQueryModalFooterProps> = (props: {
diff --git a/superset-frontend/src/explore/controls.tsx b/superset-frontend/src/explore/controls.tsx
index 91e91de6867..b58808feebe 100644
--- a/superset-frontend/src/explore/controls.tsx
+++ b/superset-frontend/src/explore/controls.tsx
@@ -64,6 +64,7 @@ import {
   validateNonEmpty,
 } from '@superset-ui/core';
 import { t } from '@apache-superset/core/translation';
+import { datasetLabel } from 'src/utils/semanticLayerLabels';
 import { formatSelectOptions } from 'src/explore/exploreUtils';
 import { TIME_FILTER_LABELS } from './constants';
 import { StyledColumnOption } from './components/optionRenderers';
@@ -214,7 +215,7 @@ export const controls = {
 
   datasource: {
     type: 'DatasourceControl',
-    label: t('Dataset'),
+    label: datasetLabel(),
     default: null,
     description: null,
     mapStateToProps: ({ datasource }: ControlState) => ({
diff --git a/superset-frontend/src/pages/ChartCreation/index.tsx b/superset-frontend/src/pages/ChartCreation/index.tsx
index bea1756ab53..05a7b8f3486 100644
--- a/superset-frontend/src/pages/ChartCreation/index.tsx
+++ b/superset-frontend/src/pages/ChartCreation/index.tsx
@@ -44,6 +44,7 @@ import {
   DatasetSelectLabel,
 } from 'src/features/datasets/DatasetSelectLabel';
 import { Icons } from '@superset-ui/core/components/Icons';
+import { datasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
 
 export interface ChartCreationProps extends RouteComponentProps {
   user: UserWithPermissionsAndRoles;
@@ -210,7 +211,9 @@ export class ChartCreation extends PureComponent<
         .catch(() => {
           this.setState({ loading: false });
         });
-      this.props.addSuccessToast(t('The dataset has been saved'));
+      this.props.addSuccessToast(
+        t('The %s has been saved', datasetLabelLower()),
+      );
     }
   }
 
@@ -295,7 +298,7 @@ export class ChartCreation extends PureComponent<
     const datasetHelpText = this.state.canCreateDataset ? (
       <span data-test="dataset-write">
         <Link to="/dataset/add/" data-test="add-chart-new-dataset">
-          {t('Add a dataset')}
+          {t('Add a %s', datasetLabelLower())}
         </Link>{' '}
         {t('or')}{' '}
         <a
@@ -332,18 +335,22 @@ export class ChartCreation extends PureComponent<
         <h3>{t('Create a new chart')}</h3>
         <Steps direction="vertical" size="small">
           <Steps.Step
-            title={<StyledStepTitle>{t('Choose a dataset')}</StyledStepTitle>}
+            title={
+              <StyledStepTitle>
+                {t('Choose a %s', datasetLabelLower())}
+              </StyledStepTitle>
+            }
             status={this.state.datasource?.value ? 'finish' : 'process'}
             description={
               <StyledStepDescription className="dataset">
                 <AsyncSelect
                   autoFocus
-                  ariaLabel={t('Dataset')}
+                  ariaLabel={datasetLabel()}
                   name="select-datasource"
                   onChange={this.changeDatasource}
                   options={this.loadDatasources}
                   optionFilterProps={['id', 'table_name']}
-                  placeholder={t('Choose a dataset')}
+                  placeholder={t('Choose a %s', datasetLabelLower())}
                   showSearch
                   value={this.state.datasource}
                 />
@@ -370,7 +377,10 @@ export class ChartCreation extends PureComponent<
         <div className="footer">
           {isButtonDisabled && (
             <span>
-              {t('Please select both a Dataset and a Chart type to proceed')}
+              {t(
+                'Please select both a %s and a Chart type to proceed',
+                datasetLabel(),
+              )}
             </span>
           )}
           <Button
diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx
index dc91a9028b7..f11f40c5f1f 100644
--- a/superset-frontend/src/pages/ChartList/index.tsx
+++ b/superset-frontend/src/pages/ChartList/index.tsx
@@ -83,6 +83,7 @@ import { findPermission } from 'src/utils/findPermission';
 import { QueryObjectColumns } from 'src/views/CRUD/types';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
 import { Tag } from 'src/components/Tag';
+import { datasetLabel } from 'src/utils/semanticLayerLabels';
 
 const FlexRowContainer = styled.div`
   align-items: center;
@@ -430,7 +431,7 @@ function ChartList(props: ChartListProps) {
             </Tooltip>
           );
         },
-        Header: t('Dataset'),
+        Header: datasetLabel(),
         accessor: 'datasource_id',
         disableSortBy: true,
         size: 'xl',
@@ -658,7 +659,7 @@ function ChartList(props: ChartListProps) {
           }),
       },
       {
-        Header: t('Dataset'),
+        Header: datasetLabel(),
         key: 'dataset',
         id: 'datasource_id',
         input: 'select',


(superset) 06/25: feat: CRUD for adding/deleting semantic views

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 8c69f13a717640f14d1310082f284a466c28e3d0
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Mar 4 10:56:44 2026 -0500

    feat: CRUD for adding/deleting semantic views
---
 .../features/semanticLayers/SemanticLayerModal.tsx | 253 +----------
 .../features/semanticLayers/jsonFormsHelpers.tsx   | 257 +++++++++++
 .../semanticViews/AddSemanticViewModal.tsx         | 475 +++++++++++++++++++++
 superset-frontend/src/pages/DatasetList/index.tsx  | 173 ++++++--
 superset/commands/semantic_layer/create.py         |  37 +-
 superset/commands/semantic_layer/delete.py         |  29 +-
 superset/commands/semantic_layer/exceptions.py     |   8 +
 superset/semantic_layers/api.py                    | 198 ++++++++-
 superset/semantic_layers/schemas.py                |   8 +
 .../commands/semantic_layer/create_test.py         |  81 ++++
 .../commands/semantic_layer/delete_test.py         |  33 ++
 11 files changed, 1277 insertions(+), 275 deletions(-)

diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
index 7803138542f..6a29e804e7a 100644
--- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
+++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx
@@ -20,27 +20,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { styled } from '@apache-superset/core/theme';
 import { SupersetClient } from '@superset-ui/core';
-import { Input, Spin } from 'antd';
+import { Input } from 'antd';
 import { Select } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
-import { JsonForms, withJsonFormsControlProps } from '@jsonforms/react';
-import type {
-  JsonSchema,
-  UISchemaElement,
-  ControlProps,
-} from '@jsonforms/core';
-import {
-  rankWith,
-  and,
-  isStringControl,
-  formatIs,
-  schemaMatches,
-} from '@jsonforms/core';
-import {
-  rendererRegistryEntries,
-  cellRegistryEntries,
-  TextControl,
-} from '@great-expectations/jsonforms-antd-renderers';
+import { JsonForms } from '@jsonforms/react';
+import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
+import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
 import type { ErrorObject } from 'ajv';
 import {
   StandardModal,
@@ -48,229 +33,19 @@ import {
   MODAL_STANDARD_WIDTH,
   MODAL_MEDIUM_WIDTH,
 } from 'src/components/Modal';
-
-/**
- * Custom renderer that renders `Input.Password` for fields with
- * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
- */
-function PasswordControl(props: ControlProps) {
-  const uischema = {
-    ...props.uischema,
-    options: { ...props.uischema.options, type: 'password' },
-  };
-  return TextControl({ ...props, uischema });
-}
-const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
-const passwordEntry = {
-  tester: rankWith(3, and(isStringControl, formatIs('password'))),
-  renderer: PasswordRenderer,
-};
-
-/**
- * Renderer for `const` properties (e.g. Pydantic discriminator fields).
- * Renders nothing visually but ensures the const value is set in form data,
- * so discriminated unions resolve correctly on the backend.
- */
-function ConstControl({ data, handleChange, path, schema }: ControlProps) {
-  const constValue = (schema as Record<string, unknown>).const;
-  useEffect(() => {
-    if (constValue !== undefined && data !== constValue) {
-      handleChange(path, constValue);
-    }
-  }, [constValue, data, handleChange, path]);
-  return null;
-}
-const ConstRenderer = withJsonFormsControlProps(ConstControl);
-const constEntry = {
-  tester: rankWith(
-    10,
-    schemaMatches(s => s !== undefined && 'const' in s),
-  ),
-  renderer: ConstRenderer,
-};
-
-/**
- * Checks whether all dependency values are filled (non-empty).
- * Handles nested objects (like auth) by checking they have at least one key.
- */
-function areDependenciesSatisfied(
-  dependencies: string[],
-  data: Record<string, unknown>,
-): boolean {
-  return dependencies.every(dep => {
-    const value = data[dep];
-    if (value === null || value === undefined || value === '') return false;
-    if (typeof value === 'object' && Object.keys(value).length === 0)
-      return false;
-    return true;
-  });
-}
-
-/**
- * Renderer for fields marked `x-dynamic` in the JSON Schema.
- * Shows a loading spinner inside the input while the schema is being
- * refreshed with dynamic values from the backend.
- */
-function DynamicFieldControl(props: ControlProps) {
-  const { refreshingSchema, formData: cfgData } = props.config ?? {};
-  const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
-  const refreshing =
-    refreshingSchema &&
-    Array.isArray(deps) &&
-    areDependenciesSatisfied(
-      deps as string[],
-      (cfgData as Record<string, unknown>) ?? {},
-    );
-
-  if (!refreshing) {
-    return TextControl(props);
-  }
-
-  const uischema = {
-    ...props.uischema,
-    options: {
-      ...props.uischema.options,
-      placeholderText: t('Loading...'),
-      inputProps: { suffix: <Spin size="small" /> },
-    },
-  };
-  return TextControl({ ...props, uischema, enabled: false });
-}
-const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
-const dynamicFieldEntry = {
-  tester: rankWith(
-    3,
-    and(
-      isStringControl,
-      schemaMatches(
-        s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
-      ),
-    ),
-  ),
-  renderer: DynamicFieldRenderer,
-};
-
-const renderers = [
-  ...rendererRegistryEntries,
-  passwordEntry,
-  constEntry,
-  dynamicFieldEntry,
-];
+import {
+  renderers,
+  sanitizeSchema,
+  buildUiSchema,
+  getDynamicDependencies,
+  areDependenciesSatisfied,
+  serializeDependencyValues,
+  SCHEMA_REFRESH_DEBOUNCE_MS,
+} from './jsonFormsHelpers';
 
 type Step = 'type' | 'config';
 type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
 
-const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
-
-/**
- * Removes empty `enum` arrays from schema properties. The JSON Schema spec
- * requires `enum` to have at least one item, and AJV rejects empty arrays.
- * Fields with empty enums are rendered as plain text inputs instead.
- */
-function sanitizeSchema(schema: JsonSchema): JsonSchema {
-  if (!schema.properties) return schema;
-  const properties: Record<string, JsonSchema> = {};
-  for (const [key, prop] of Object.entries(schema.properties)) {
-    if (
-      typeof prop === 'object' &&
-      prop !== null &&
-      'enum' in prop &&
-      Array.isArray(prop.enum) &&
-      prop.enum.length === 0
-    ) {
-      const { enum: _empty, ...rest } = prop;
-      properties[key] = rest;
-    } else {
-      properties[key] = prop as JsonSchema;
-    }
-  }
-  return { ...schema, properties } as JsonSchema;
-}
-
-/**
- * Builds a JSON Forms UI schema from a JSON Schema, using the first
- * `examples` entry as placeholder text for each string property.
- */
-function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined {
-  if (!schema.properties) return undefined;
-
-  // Use explicit property order from backend if available,
-  // otherwise fall back to the JSON object key order
-  const propertyOrder: string[] =
-    ((schema as Record<string, unknown>)['x-propertyOrder'] as string[]) ??
-    Object.keys(schema.properties);
-
-  const elements = propertyOrder
-    .filter(key => key in (schema.properties ?? {}))
-    .map(key => {
-      const prop = schema.properties![key];
-      const control: Record<string, unknown> = {
-        type: 'Control',
-        scope: `#/properties/${key}`,
-      };
-      if (typeof prop === 'object' && prop !== null) {
-        const options: Record<string, unknown> = {};
-        if (
-          'examples' in prop &&
-          Array.isArray(prop.examples) &&
-          prop.examples.length > 0
-        ) {
-          options.placeholderText = String(prop.examples[0]);
-        }
-        if ('description' in prop && typeof prop.description === 'string') {
-          options.tooltip = prop.description;
-        }
-        if (Object.keys(options).length > 0) {
-          control.options = options;
-        }
-      }
-      return control;
-    });
-  return { type: 'VerticalLayout', elements } as UISchemaElement;
-}
-
-/**
- * Extracts dynamic field dependency mappings from the schema.
- * Returns a map of field name → list of dependency field names.
- */
-function getDynamicDependencies(schema: JsonSchema): Record<string, string[]> {
-  const deps: Record<string, string[]> = {};
-  if (!schema.properties) return deps;
-  for (const [key, prop] of Object.entries(schema.properties)) {
-    if (
-      typeof prop === 'object' &&
-      prop !== null &&
-      'x-dynamic' in prop &&
-      'x-dependsOn' in prop &&
-      Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
-    ) {
-      deps[key] = (prop as Record<string, unknown>)['x-dependsOn'] as string[];
-    }
-  }
-  return deps;
-}
-
-/**
- * Serializes the dependency values for a set of fields into a stable string
- * for comparison, so we only re-fetch when dependency values actually change.
- */
-function serializeDependencyValues(
-  dynamicDeps: Record<string, string[]>,
-  data: Record<string, unknown>,
-): string {
-  const allDepKeys = new Set<string>();
-  for (const deps of Object.values(dynamicDeps)) {
-    for (const dep of deps) {
-      allDepKeys.add(dep);
-    }
-  }
-  const snapshot: Record<string, unknown> = {};
-  for (const key of [...allDepKeys].sort()) {
-    snapshot[key] = data[key];
-  }
-  return JSON.stringify(snapshot);
-}
-
 const ModalContent = styled.div`
   padding: ${({ theme }) => theme.sizeUnit * 4}px;
 `;
@@ -395,7 +170,7 @@ export default function SemanticLayerModal({
         setSelectedType(layer.type);
         setFormData(layer.configuration ?? {});
         setHasErrors(false);
-        // Fetch base schema (no configuration → no Snowflake connection) to
+        // Fetch base schema (no configuration -> no Snowflake connection) to
         // show the form immediately. The existing maybeRefreshSchema machinery
         // will trigger an enriched fetch in the background once deps are
         // satisfied, and DynamicFieldControl will show per-field spinners.
diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
new file mode 100644
index 00000000000..6b21e73e9ca
--- /dev/null
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -0,0 +1,257 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useEffect } from 'react';
+import { t } from '@apache-superset/core';
+import { Spin } from 'antd';
+import { withJsonFormsControlProps } from '@jsonforms/react';
+import type {
+  JsonSchema,
+  UISchemaElement,
+  ControlProps,
+} from '@jsonforms/core';
+import {
+  rankWith,
+  and,
+  isStringControl,
+  formatIs,
+  schemaMatches,
+} from '@jsonforms/core';
+import {
+  rendererRegistryEntries,
+  TextControl,
+} from '@great-expectations/jsonforms-antd-renderers';
+
+export const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
+
+/**
+ * Custom renderer that renders `Input.Password` for fields with
+ * `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
+ */
+function PasswordControl(props: ControlProps) {
+  const uischema = {
+    ...props.uischema,
+    options: { ...props.uischema.options, type: 'password' },
+  };
+  return TextControl({ ...props, uischema });
+}
+const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
+const passwordEntry = {
+  tester: rankWith(3, and(isStringControl, formatIs('password'))),
+  renderer: PasswordRenderer,
+};
+
+/**
+ * Renderer for `const` properties (e.g. Pydantic discriminator fields).
+ * Renders nothing visually but ensures the const value is set in form data,
+ * so discriminated unions resolve correctly on the backend.
+ */
+function ConstControl({ data, handleChange, path, schema }: ControlProps) {
+  const constValue = (schema as Record<string, unknown>).const;
+  useEffect(() => {
+    if (constValue !== undefined && data !== constValue) {
+      handleChange(path, constValue);
+    }
+  }, [constValue, data, handleChange, path]);
+  return null;
+}
+const ConstRenderer = withJsonFormsControlProps(ConstControl);
+const constEntry = {
+  tester: rankWith(10, schemaMatches(s => s !== undefined && 'const' in s)),
+  renderer: ConstRenderer,
+};
+
+/**
+ * Renderer for fields marked `x-dynamic` in the JSON Schema.
+ * Shows a loading spinner inside the input while the schema is being
+ * refreshed with dynamic values from the backend.
+ */
+function DynamicFieldControl(props: ControlProps) {
+  const { refreshingSchema, formData: cfgData } = props.config ?? {};
+  const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
+  const refreshing =
+    refreshingSchema &&
+    Array.isArray(deps) &&
+    areDependenciesSatisfied(deps as string[], (cfgData as Record<string, unknown>) ?? {});
+
+  if (!refreshing) {
+    return TextControl(props);
+  }
+
+  const uischema = {
+    ...props.uischema,
+    options: {
+      ...props.uischema.options,
+      placeholderText: t('Loading...'),
+      inputProps: { suffix: <Spin size="small" /> },
+    },
+  };
+  return TextControl({ ...props, uischema, enabled: false });
+}
+const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
+const dynamicFieldEntry = {
+  tester: rankWith(
+    3,
+    and(
+      isStringControl,
+      schemaMatches(
+        s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
+      ),
+    ),
+  ),
+  renderer: DynamicFieldRenderer,
+};
+
+export const renderers = [
+  ...rendererRegistryEntries,
+  passwordEntry,
+  constEntry,
+  dynamicFieldEntry,
+];
+
+/**
+ * Removes empty `enum` arrays from schema properties. The JSON Schema spec
+ * requires `enum` to have at least one item, and AJV rejects empty arrays.
+ * Fields with empty enums are rendered as plain text inputs instead.
+ */
+export function sanitizeSchema(schema: JsonSchema): JsonSchema {
+  if (!schema.properties) return schema;
+  const properties: Record<string, JsonSchema> = {};
+  for (const [key, prop] of Object.entries(schema.properties)) {
+    if (
+      typeof prop === 'object' &&
+      prop !== null &&
+      'enum' in prop &&
+      Array.isArray(prop.enum) &&
+      prop.enum.length === 0
+    ) {
+      const { enum: _empty, ...rest } = prop;
+      properties[key] = rest;
+    } else {
+      properties[key] = prop as JsonSchema;
+    }
+  }
+  return { ...schema, properties };
+}
+
+/**
+ * Builds a JSON Forms UI schema from a JSON Schema, using the first
+ * `examples` entry as placeholder text for each string property.
+ */
+export function buildUiSchema(
+  schema: JsonSchema,
+): UISchemaElement | undefined {
+  if (!schema.properties) return undefined;
+
+  // Use explicit property order from backend if available,
+  // otherwise fall back to the JSON object key order
+  const propertyOrder: string[] =
+    (schema as Record<string, unknown>)['x-propertyOrder'] as string[] ??
+    Object.keys(schema.properties);
+
+  const elements = propertyOrder
+    .filter(key => key in (schema.properties ?? {}))
+    .map(key => {
+      const prop = schema.properties![key];
+      const control: Record<string, unknown> = {
+        type: 'Control',
+        scope: `#/properties/${key}`,
+      };
+      if (typeof prop === 'object' && prop !== null) {
+        const options: Record<string, unknown> = {};
+        if (
+          'examples' in prop &&
+          Array.isArray(prop.examples) &&
+          prop.examples.length > 0
+        ) {
+          options.placeholderText = String(prop.examples[0]);
+        }
+        if ('description' in prop && typeof prop.description === 'string') {
+          options.tooltip = prop.description;
+        }
+        if (Object.keys(options).length > 0) {
+          control.options = options;
+        }
+      }
+      return control;
+    });
+  return { type: 'VerticalLayout', elements } as UISchemaElement;
+}
+
+/**
+ * Extracts dynamic field dependency mappings from the schema.
+ * Returns a map of field name -> list of dependency field names.
+ */
+export function getDynamicDependencies(
+  schema: JsonSchema,
+): Record<string, string[]> {
+  const deps: Record<string, string[]> = {};
+  if (!schema.properties) return deps;
+  for (const [key, prop] of Object.entries(schema.properties)) {
+    if (
+      typeof prop === 'object' &&
+      prop !== null &&
+      'x-dynamic' in prop &&
+      'x-dependsOn' in prop &&
+      Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
+    ) {
+      deps[key] = (prop as Record<string, unknown>)[
+        'x-dependsOn'
+      ] as string[];
+    }
+  }
+  return deps;
+}
+
+/**
+ * Checks whether all dependency values are filled (non-empty).
+ * Handles nested objects (like auth) by checking they have at least one key.
+ */
+export function areDependenciesSatisfied(
+  dependencies: string[],
+  data: Record<string, unknown>,
+): boolean {
+  return dependencies.every(dep => {
+    const value = data[dep];
+    if (value === null || value === undefined || value === '') return false;
+    if (typeof value === 'object' && Object.keys(value).length === 0)
+      return false;
+    return true;
+  });
+}
+
+/**
+ * Serializes the dependency values for a set of fields into a stable string
+ * for comparison, so we only re-fetch when dependency values actually change.
+ */
+export function serializeDependencyValues(
+  dynamicDeps: Record<string, string[]>,
+  data: Record<string, unknown>,
+): string {
+  const allDepKeys = new Set<string>();
+  for (const deps of Object.values(dynamicDeps)) {
+    for (const dep of deps) {
+      allDepKeys.add(dep);
+    }
+  }
+  const snapshot: Record<string, unknown> = {};
+  for (const key of [...allDepKeys].sort()) {
+    snapshot[key] = data[key];
+  }
+  return JSON.stringify(snapshot);
+}
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
new file mode 100644
index 00000000000..505eab8f18b
--- /dev/null
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -0,0 +1,475 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { t } from '@apache-superset/core';
+import { styled } from '@apache-superset/core/ui';
+import { SupersetClient } from '@superset-ui/core';
+import { Checkbox, Spin } from 'antd';
+import { Select } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { JsonForms } from '@jsonforms/react';
+import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
+import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
+import type { ErrorObject } from 'ajv';
+import {
+  StandardModal,
+  ModalFormField,
+  MODAL_STANDARD_WIDTH,
+  MODAL_MEDIUM_WIDTH,
+} from 'src/components/Modal';
+import {
+  renderers,
+  sanitizeSchema,
+  buildUiSchema,
+  getDynamicDependencies,
+  areDependenciesSatisfied,
+  serializeDependencyValues,
+  SCHEMA_REFRESH_DEBOUNCE_MS,
+} from 'src/features/semanticLayers/jsonFormsHelpers';
+
+type Step = 'layer' | 'configure' | 'select';
+
+interface SemanticLayerOption {
+  uuid: string;
+  name: string;
+}
+
+interface AvailableView {
+  name: string;
+  already_added: boolean;
+}
+
+const ModalContent = styled.div`
+  padding: ${({ theme }) => theme.sizeUnit * 4}px;
+`;
+
+const BackLink = styled.button`
+  background: none;
+  border: none;
+  color: ${({ theme }) => theme.colorPrimary};
+  cursor: pointer;
+  padding: 0;
+  font-size: ${({ theme }) => theme.fontSize[1]}px;
+  margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
+  display: inline-flex;
+  align-items: center;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+
+  &:hover {
+    text-decoration: underline;
+  }
+`;
+
+const ViewList = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: ${({ theme }) => theme.sizeUnit}px;
+`;
+
+const LoadingContainer = styled.div`
+  display: flex;
+  justify-content: center;
+  padding: ${({ theme }) => theme.sizeUnit * 6}px;
+`;
+
+interface AddSemanticViewModalProps {
+  show: boolean;
+  onHide: () => void;
+  onSuccess: () => void;
+  addDangerToast: (msg: string) => void;
+  addSuccessToast: (msg: string) => void;
+}
+
+export default function AddSemanticViewModal({
+  show,
+  onHide,
+  onSuccess,
+  addDangerToast,
+  addSuccessToast,
+}: AddSemanticViewModalProps) {
+  const [step, setStep] = useState<Step>('layer');
+  const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
+  const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
+    null,
+  );
+  const [loading, setLoading] = useState(false);
+  const [saving, setSaving] = useState(false);
+
+  // Step 2: Configure (runtime schema)
+  const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
+  const [runtimeUiSchema, setRuntimeUiSchema] = useState<
+    UISchemaElement | undefined
+  >(undefined);
+  const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
+  const [refreshingSchema, setRefreshingSchema] = useState(false);
+  const [hasRuntimeErrors, setHasRuntimeErrors] = useState(false);
+  const errorsRef = useRef<ErrorObject[]>([]);
+  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const lastDepSnapshotRef = useRef<string>('');
+  const dynamicDepsRef = useRef<Record<string, string[]>>({});
+
+  // Step 3: Select views
+  const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
+  const [selectedViews, setSelectedViews] = useState<Set<string>>(new Set());
+  const [loadingViews, setLoadingViews] = useState(false);
+
+  // Reset state when modal closes
+  useEffect(() => {
+    if (show) {
+      fetchLayers();
+    } else {
+      setStep('layer');
+      setLayers([]);
+      setSelectedLayerUuid(null);
+      setLoading(false);
+      setSaving(false);
+      setRuntimeSchema(null);
+      setRuntimeUiSchema(undefined);
+      setRuntimeData({});
+      setRefreshingSchema(false);
+      setHasRuntimeErrors(false);
+      errorsRef.current = [];
+      lastDepSnapshotRef.current = '';
+      dynamicDepsRef.current = {};
+      setAvailableViews([]);
+      setSelectedViews(new Set());
+      setLoadingViews(false);
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    }
+  }, [show]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  const fetchLayers = async () => {
+    setLoading(true);
+    try {
+      const { json } = await SupersetClient.get({
+        endpoint: '/api/v1/semantic_layer/',
+      });
+      setLayers(
+        (json.result ?? []).map((l: { uuid: string; name: string }) => ({
+          uuid: l.uuid,
+          name: l.name,
+        })),
+      );
+    } catch {
+      addDangerToast(t('An error occurred while fetching semantic layers'));
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
+    const schema = sanitizeSchema(rawSchema);
+    setRuntimeSchema(schema);
+    setRuntimeUiSchema(buildUiSchema(schema));
+    dynamicDepsRef.current = getDynamicDependencies(rawSchema);
+  }, []);
+
+  const fetchRuntimeSchema = useCallback(
+    async (uuid: string, currentRuntimeData?: Record<string, unknown>) => {
+      const isInitialFetch = !currentRuntimeData;
+      if (isInitialFetch) setLoading(true);
+      else setRefreshingSchema(true);
+      try {
+        const { json } = await SupersetClient.post({
+          endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
+          jsonPayload: currentRuntimeData
+            ? { runtime_data: currentRuntimeData }
+            : {},
+        });
+        const schema = json.result;
+        if (
+          isInitialFetch &&
+          (!schema ||
+            !schema.properties ||
+            Object.keys(schema.properties).length === 0)
+        ) {
+          // No runtime config needed — skip to step 3
+          fetchViews(uuid, {});
+        } else if (isInitialFetch) {
+          applyRuntimeSchema(schema);
+          setStep('configure');
+        } else {
+          applyRuntimeSchema(schema);
+        }
+      } catch {
+        if (isInitialFetch) {
+          addDangerToast(
+            t('An error occurred while fetching the runtime schema'),
+          );
+        }
+      } finally {
+        if (isInitialFetch) setLoading(false);
+        else setRefreshingSchema(false);
+      }
+    },
+    [addDangerToast, applyRuntimeSchema], // eslint-disable-line react-hooks/exhaustive-deps
+  );
+
+  const fetchViews = useCallback(
+    async (uuid: string, rData: Record<string, unknown>) => {
+      setLoadingViews(true);
+      setStep('select');
+      try {
+        const { json } = await SupersetClient.post({
+          endpoint: `/api/v1/semantic_layer/${uuid}/views`,
+          jsonPayload: { runtime_data: rData },
+        });
+        const views: AvailableView[] = json.result ?? [];
+        setAvailableViews(views);
+        // Pre-select views that are already added (disabled anyway)
+        setSelectedViews(
+          new Set(views.filter(v => v.already_added).map(v => v.name)),
+        );
+      } catch {
+        addDangerToast(t('An error occurred while fetching available views'));
+      } finally {
+        setLoadingViews(false);
+      }
+    },
+    [addDangerToast],
+  );
+
+  const maybeRefreshRuntimeSchema = useCallback(
+    (data: Record<string, unknown>) => {
+      if (!selectedLayerUuid) return;
+
+      const dynamicDeps = dynamicDepsRef.current;
+      if (Object.keys(dynamicDeps).length === 0) return;
+
+      const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
+        areDependenciesSatisfied(deps, data),
+      );
+      if (!hasSatisfiedDeps) return;
+
+      const snapshot = serializeDependencyValues(dynamicDeps, data);
+      if (snapshot === lastDepSnapshotRef.current) return;
+      lastDepSnapshotRef.current = snapshot;
+
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+      debounceTimerRef.current = setTimeout(() => {
+        fetchRuntimeSchema(selectedLayerUuid, data);
+      }, SCHEMA_REFRESH_DEBOUNCE_MS);
+    },
+    [selectedLayerUuid, fetchRuntimeSchema],
+  );
+
+  const handleRuntimeFormChange = useCallback(
+    ({
+      data,
+      errors,
+    }: {
+      data: Record<string, unknown>;
+      errors?: ErrorObject[];
+    }) => {
+      setRuntimeData(data);
+      errorsRef.current = errors ?? [];
+      setHasRuntimeErrors(errorsRef.current.length > 0);
+      maybeRefreshRuntimeSchema(data);
+    },
+    [maybeRefreshRuntimeSchema],
+  );
+
+  const handleToggleView = (viewName: string, checked: boolean) => {
+    setSelectedViews(prev => {
+      const next = new Set(prev);
+      if (checked) {
+        next.add(viewName);
+      } else {
+        next.delete(viewName);
+      }
+      return next;
+    });
+  };
+
+  const newViewCount = availableViews.filter(
+    v => selectedViews.has(v.name) && !v.already_added,
+  ).length;
+
+  const handleAddViews = async () => {
+    if (!selectedLayerUuid) return;
+    setSaving(true);
+    try {
+      const viewsToCreate = availableViews
+        .filter(v => selectedViews.has(v.name) && !v.already_added)
+        .map(v => ({
+          name: v.name,
+          semantic_layer_uuid: selectedLayerUuid,
+          configuration: runtimeData,
+        }));
+
+      await SupersetClient.post({
+        endpoint: '/api/v1/semantic_view/',
+        jsonPayload: { views: viewsToCreate },
+      });
+
+      addSuccessToast(t('%s semantic view(s) added', viewsToCreate.length));
+      onSuccess();
+      onHide();
+    } catch {
+      addDangerToast(t('An error occurred while adding semantic views'));
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const handleSave = () => {
+    if (step === 'layer' && selectedLayerUuid) {
+      fetchRuntimeSchema(selectedLayerUuid);
+    } else if (step === 'configure' && selectedLayerUuid) {
+      fetchViews(selectedLayerUuid, runtimeData);
+    } else if (step === 'select') {
+      handleAddViews();
+    }
+  };
+
+  const handleBack = () => {
+    if (step === 'configure') {
+      setStep('layer');
+      setRuntimeSchema(null);
+      setRuntimeUiSchema(undefined);
+      setRuntimeData({});
+      setHasRuntimeErrors(false);
+      errorsRef.current = [];
+      lastDepSnapshotRef.current = '';
+      dynamicDepsRef.current = {};
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    } else if (step === 'select') {
+      // Go back to configure if we had a runtime schema, otherwise to layer
+      if (runtimeSchema) {
+        setStep('configure');
+      } else {
+        setStep('layer');
+      }
+      setAvailableViews([]);
+      setSelectedViews(new Set());
+    }
+  };
+
+  const title =
+    step === 'layer'
+      ? t('Add Semantic View')
+      : step === 'configure'
+        ? t('Configure')
+        : t('Select Views');
+
+  const saveText =
+    step === 'select' ? t('Add %s view(s)', newViewCount) : t('Next');
+
+  const saveDisabled =
+    step === 'layer'
+      ? !selectedLayerUuid
+      : step === 'configure'
+        ? hasRuntimeErrors
+        : step === 'select'
+          ? newViewCount === 0 || saving
+          : false;
+
+  const modalWidth =
+    step === 'configure' ? MODAL_MEDIUM_WIDTH : MODAL_STANDARD_WIDTH;
+
+  return (
+    <StandardModal
+      show={show}
+      onHide={onHide}
+      onSave={handleSave}
+      title={title}
+      icon={<Icons.PlusOutlined />}
+      width={modalWidth}
+      saveDisabled={saveDisabled}
+      saveText={saveText}
+      saveLoading={saving}
+      contentLoading={loading}
+    >
+      {step === 'layer' && (
+        <ModalContent>
+          <ModalFormField label={t('Semantic Layer')}>
+            <Select
+              ariaLabel={t('Semantic layer')}
+              placeholder={t('Select a semantic layer')}
+              value={selectedLayerUuid}
+              onChange={value => setSelectedLayerUuid(value as string)}
+              options={layers.map(l => ({
+                value: l.uuid,
+                label: l.name,
+              }))}
+              getPopupContainer={() => document.body}
+              dropdownAlign={{
+                points: ['tl', 'bl'],
+                offset: [0, 4],
+                overflow: { adjustX: 0, adjustY: 1 },
+              }}
+            />
+          </ModalFormField>
+        </ModalContent>
+      )}
+
+      {step === 'configure' && (
+        <ModalContent>
+          <BackLink type="button" onClick={handleBack}>
+            <Icons.CaretLeftOutlined iconSize="s" />
+            {t('Back')}
+          </BackLink>
+          {runtimeSchema && (
+            <JsonForms
+              schema={runtimeSchema}
+              uischema={runtimeUiSchema}
+              data={runtimeData}
+              renderers={renderers}
+              cells={cellRegistryEntries}
+              config={{ refreshingSchema, formData: runtimeData }}
+              validationMode="ValidateAndHide"
+              onChange={handleRuntimeFormChange}
+            />
+          )}
+        </ModalContent>
+      )}
+
+      {step === 'select' && (
+        <ModalContent>
+          <BackLink type="button" onClick={handleBack}>
+            <Icons.CaretLeftOutlined iconSize="s" />
+            {t('Back')}
+          </BackLink>
+          {loadingViews ? (
+            <LoadingContainer>
+              <Spin />
+            </LoadingContainer>
+          ) : (
+            <ViewList>
+              {availableViews.map(view => (
+                <Checkbox
+                  key={view.name}
+                  checked={selectedViews.has(view.name)}
+                  disabled={view.already_added}
+                  onChange={e => handleToggleView(view.name, e.target.checked)}
+                >
+                  {view.name}
+                  {view.already_added && <span> ({t('Already added')})</span>}
+                </Checkbox>
+              ))}
+              {availableViews.length === 0 && !loadingViews && (
+                <span>{t('No views available')}</span>
+              )}
+            </ViewList>
+          )}
+        </ModalContent>
+      )}
+    </StandardModal>
+  );
+}
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index 43fe36cacb0..107e8e936fe 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -38,9 +38,11 @@ import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'
 import { ColumnObject } from 'src/features/datasets/types';
 import { useListViewResource } from 'src/views/CRUD/hooks';
 import {
+  Button,
   ConfirmStatusChange,
   CertifiedBadge,
   DeleteModal,
+  Dropdown,
   Tooltip,
   InfoTooltip,
   DatasetTypeLabel,
@@ -77,6 +79,7 @@ import {
 import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
 import type DatasetType from 'src/types/Dataset';
 import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
+import AddSemanticViewModal from 'src/features/semanticViews/AddSemanticViewModal';
 import { useSelector } from 'react-redux';
 import { QueryObjectColumns } from 'src/views/CRUD/types';
 import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -267,6 +270,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     null,
   );
 
+  const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
+    useState(false);
   const [importingDataset, showImportModal] = useState<boolean>(false);
   const [passwordFields, setPasswordFields] = useState<string[]>([]);
   const [preparingExport, setPreparingExport] = useState<boolean>(false);
@@ -551,25 +556,43 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         Cell: ({ row: { original } }: CellProps<Dataset>) => {
           const isSemanticView = original.kind === 'semantic_view';
 
-          // Semantic view: only show edit button
+          // Semantic view: show edit and delete buttons
           if (isSemanticView) {
-            if (!canEdit) return null;
+            if (!canEdit && !canDelete) return null;
             return (
               <Actions className="actions">
-                <Tooltip
-                  id="edit-action-tooltip"
-                  title={t('Edit')}
-                  placement="bottom"
-                >
-                  <span
-                    role="button"
-                    tabIndex={0}
-                    className="action-button"
-                    onClick={() => setSvCurrentlyEditing(original)}
+                {canDelete && (
+                  <Tooltip
+                    id="delete-action-tooltip"
+                    title={t('Delete')}
+                    placement="bottom"
                   >
-                    <Icons.EditOutlined iconSize="l" />
-                  </span>
-                </Tooltip>
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => handleSemanticViewDelete(original)}
+                    >
+                      <Icons.DeleteOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
+                {canEdit && (
+                  <Tooltip
+                    id="edit-action-tooltip"
+                    title={t('Edit')}
+                    placement="bottom"
+                  >
+                    <span
+                      role="button"
+                      tabIndex={0}
+                      className="action-button"
+                      onClick={() => setSvCurrentlyEditing(original)}
+                    >
+                      <Icons.EditOutlined iconSize="l" />
+                    </span>
+                  </Tooltip>
+                )}
               </Actions>
             );
           }
@@ -881,14 +904,58 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
   }
 
   if (canCreate) {
-    buttonArr.push({
-      icon: <Icons.PlusOutlined iconSize="m" />,
-      name: t('Dataset'),
-      onClick: () => {
-        history.push('/dataset/add/');
-      },
-      buttonStyle: 'primary',
-    });
+    if (isFeatureEnabled(FeatureFlag.SemanticLayers)) {
+      buttonArr.push({
+        name: t('New'),
+        buttonStyle: 'primary',
+        component: (
+          <Dropdown
+            css={css`
+              margin-left: ${theme.sizeUnit * 2}px;
+            `}
+            menu={{
+              items: [
+                {
+                  key: 'dataset',
+                  label: t('Dataset'),
+                  onClick: () => history.push('/dataset/add/'),
+                },
+                {
+                  key: 'semantic-view',
+                  label: t('Semantic View'),
+                  onClick: () => setShowAddSemanticViewModal(true),
+                },
+              ],
+            }}
+            trigger={['click']}
+          >
+            <Button
+              data-test="btn-create-new"
+              buttonStyle="primary"
+              icon={<Icons.PlusOutlined iconSize="m" />}
+            >
+              {t('New')}
+              <Icons.DownOutlined
+                iconSize="s"
+                css={css`
+                  margin-left: ${theme.sizeUnit * 1.5}px;
+                  margin-right: -${theme.sizeUnit * 2}px;
+                `}
+              />
+            </Button>
+          </Dropdown>
+        ),
+      });
+    } else {
+      buttonArr.push({
+        icon: <Icons.PlusOutlined iconSize="m" />,
+        name: t('Dataset'),
+        onClick: () => {
+          history.push('/dataset/add/');
+        },
+        buttonStyle: 'primary',
+      });
+    }
   }
 
   menuData.buttons = buttonArr;
@@ -922,15 +989,56 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     );
   };
 
-  const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
+  const handleSemanticViewDelete = ({ id, table_name: tableName }: Dataset) => {
     SupersetClient.delete({
-      endpoint: `/api/v1/dataset/?q=${rison.encode(
-        datasetsToDelete.map(({ id }) => id),
-      )}`,
+      endpoint: `/api/v1/semantic_view/${id}`,
     }).then(
-      ({ json = {} }) => {
+      () => {
         refreshData();
-        addSuccessToast(json.message);
+        addSuccessToast(t('Deleted: %s', tableName));
+      },
+      createErrorHandler(errMsg =>
+        addDangerToast(
+          t('There was an issue deleting %s: %s', tableName, errMsg),
+        ),
+      ),
+    );
+  };
+
+  const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
+    const datasets = datasetsToDelete.filter(
+      d => d.source_type !== 'semantic_layer',
+    );
+    const semanticViews = datasetsToDelete.filter(
+      d => d.source_type === 'semantic_layer',
+    );
+
+    const promises: Promise<unknown>[] = [];
+
+    if (datasets.length) {
+      promises.push(
+        SupersetClient.delete({
+          endpoint: `/api/v1/dataset/?q=${rison.encode(
+            datasets.map(({ id }) => id),
+          )}`,
+        }),
+      );
+    }
+
+    if (semanticViews.length) {
+      promises.push(
+        ...semanticViews.map(sv =>
+          SupersetClient.delete({
+            endpoint: `/api/v1/semantic_view/${sv.id}`,
+          }),
+        ),
+      );
+    }
+
+    Promise.all(promises).then(
+      () => {
+        refreshData();
+        addSuccessToast(t('Deleted %s item(s)', datasetsToDelete.length));
       },
       createErrorHandler(errMsg =>
         addDangerToast(
@@ -1102,6 +1210,13 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         addSuccessToast={addSuccessToast}
         semanticView={svCurrentlyEditing}
       />
+      <AddSemanticViewModal
+        show={showAddSemanticViewModal}
+        onHide={() => setShowAddSemanticViewModal(false)}
+        onSuccess={refreshData}
+        addDangerToast={addDangerToast}
+        addSuccessToast={addSuccessToast}
+      />
       <ConfirmStatusChange
         title={t('Please confirm')}
         description={t(
diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py
index 38111e07c78..8d88578aa6e 100644
--- a/superset/commands/semantic_layer/create.py
+++ b/superset/commands/semantic_layer/create.py
@@ -27,8 +27,10 @@ from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
     SemanticLayerCreateFailedError,
     SemanticLayerInvalidError,
+    SemanticLayerNotFoundError,
+    SemanticViewCreateFailedError,
 )
-from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
 from superset.semantic_layers.registry import registry
 from superset.utils import json
 from superset.utils.decorators import on_error, transaction
@@ -67,3 +69,36 @@ class CreateSemanticLayerCommand(BaseCommand):
         # Validate configuration against the plugin
         cls = registry[sl_type]
         cls.from_configuration(self._properties["configuration"])
+
+
+class CreateSemanticViewCommand(BaseCommand):
+    def __init__(self, data: dict[str, Any]):
+        self._properties = data.copy()
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError, ValueError),
+            reraise=SemanticViewCreateFailedError,
+        )
+    )
+    def run(self) -> Model:
+        self.validate()
+        if isinstance(self._properties.get("configuration"), dict):
+            self._properties["configuration"] = json.dumps(
+                self._properties["configuration"]
+            )
+        return SemanticViewDAO.create(attributes=self._properties)
+
+    def validate(self) -> None:
+        layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
+        if not SemanticLayerDAO.find_by_uuid(layer_uuid):
+            raise SemanticLayerNotFoundError()
+
+        name: str = self._properties.get("name", "")
+        configuration: dict[str, Any] = self._properties.get("configuration") or {}
+        if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
+            raise ValueError(
+                f"Semantic view '{name}' already exists for this layer"
+                " and configuration"
+            )
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 677126221a8..a69d35d0f5d 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -25,9 +25,11 @@ from superset.commands.base import BaseCommand
 from superset.commands.semantic_layer.exceptions import (
     SemanticLayerDeleteFailedError,
     SemanticLayerNotFoundError,
+    SemanticViewDeleteFailedError,
+    SemanticViewNotFoundError,
 )
-from superset.daos.semantic_layer import SemanticLayerDAO
-from superset.semantic_layers.models import SemanticLayer
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
+from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.utils.decorators import on_error, transaction
 
 logger = logging.getLogger(__name__)
@@ -54,3 +56,26 @@ class DeleteSemanticLayerCommand(BaseCommand):
         self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
         if not self._model:
             raise SemanticLayerNotFoundError()
+
+
+class DeleteSemanticViewCommand(BaseCommand):
+    def __init__(self, pk: int):
+        self._pk = pk
+        self._model: SemanticView | None = None
+
+    @transaction(
+        on_error=partial(
+            on_error,
+            catches=(SQLAlchemyError,),
+            reraise=SemanticViewDeleteFailedError,
+        )
+    )
+    def run(self) -> None:
+        self.validate()
+        assert self._model
+        SemanticViewDAO.delete([self._model])
+
+    def validate(self) -> None:
+        self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
+        if not self._model:
+            raise SemanticViewNotFoundError()
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index 1b82a65b841..ec1a4ac838d 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -66,3 +66,11 @@ class SemanticLayerUpdateFailedError(UpdateFailedError):
 
 class SemanticLayerDeleteFailedError(DeleteFailedError):
     message = _("Semantic layer could not be deleted.")
+
+
+class SemanticViewCreateFailedError(CreateFailedError):
+    message = _("Semantic view could not be created.")
+
+
+class SemanticViewDeleteFailedError(DeleteFailedError):
+    message = _("Semantic view could not be deleted.")
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 14527ffcf8a..20526c9e42b 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -27,14 +27,22 @@ from marshmallow import ValidationError
 from pydantic import ValidationError as PydanticValidationError
 
 from superset import db, event_logger, is_feature_enabled
-from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
-from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
+from superset.commands.semantic_layer.create import (
+    CreateSemanticLayerCommand,
+    CreateSemanticViewCommand,
+)
+from superset.commands.semantic_layer.delete import (
+    DeleteSemanticLayerCommand,
+    DeleteSemanticViewCommand,
+)
 from superset.commands.semantic_layer.exceptions import (
     SemanticLayerCreateFailedError,
     SemanticLayerDeleteFailedError,
     SemanticLayerInvalidError,
     SemanticLayerNotFoundError,
     SemanticLayerUpdateFailedError,
+    SemanticViewCreateFailedError,
+    SemanticViewDeleteFailedError,
     SemanticViewForbiddenError,
     SemanticViewInvalidError,
     SemanticViewNotFoundError,
@@ -45,13 +53,14 @@ from superset.commands.semantic_layer.update import (
     UpdateSemanticViewCommand,
 )
 from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
-from superset.daos.semantic_layer import SemanticLayerDAO
+from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
 from superset.models.core import Database
 from superset.semantic_layers.models import SemanticLayer, SemanticView
 from superset.semantic_layers.registry import registry
 from superset.semantic_layers.schemas import (
     SemanticLayerPostSchema,
     SemanticLayerPutSchema,
+    SemanticViewPostSchema,
     SemanticViewPutSchema,
 )
 from superset.superset_typing import FlaskResponse
@@ -161,10 +170,90 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
     allow_browser_login = True
     class_permission_name = "SemanticView"
     method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
-    include_route_methods = {"put"}
+    include_route_methods = {"put", "post", "delete"}
 
     edit_model_schema = SemanticViewPutSchema()
 
+    @expose("/", methods=("POST",))
+    @protect()
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
+        log_to_statsd=False,
+    )
+    @requires_json
+    def post(self) -> Response:
+        """Bulk create semantic views.
+        ---
+        post:
+          summary: Create semantic views
+          requestBody:
+            required: true
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    views:
+                      type: array
+                      items:
+                        type: object
+                        properties:
+                          name:
+                            type: string
+                          semantic_layer_uuid:
+                            type: string
+                          configuration:
+                            type: object
+                          description:
+                            type: string
+                          cache_timeout:
+                            type: integer
+          responses:
+            201:
+              description: Semantic views created
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        body = request.json or {}
+        views_data = body.get("views", [])
+        if not views_data:
+            return self.response_400(message="No views provided")
+
+        schema = SemanticViewPostSchema()
+        created = []
+        errors = []
+        for view_data in views_data:
+            try:
+                item = schema.load(view_data)
+            except ValidationError as error:
+                errors.append({"name": view_data.get("name"), "error": error.messages})
+                continue
+            try:
+                new_model = CreateSemanticViewCommand(item).run()
+                created.append({"uuid": str(new_model.uuid), "name": new_model.name})
+            except SemanticLayerNotFoundError:
+                errors.append(
+                    {"name": view_data.get("name"), "error": "Semantic layer not found"}
+                )
+            except SemanticViewCreateFailedError as ex:
+                logger.error(
+                    "Error creating semantic view: %s",
+                    str(ex),
+                    exc_info=True,
+                )
+                errors.append({"name": view_data.get("name"), "error": str(ex)})
+
+        result: dict[str, Any] = {"created": created}
+        if errors:
+            result["errors"] = errors
+        status = 201 if created else 422
+        return self.response(status, result=result)
+
     @expose("/<pk>", methods=("PUT",))
     @protect()
     @statsd_metrics
@@ -238,6 +327,46 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
             response = self.response_422(message=str(ex))
         return response
 
+    @expose("/<pk>", methods=("DELETE",))
+    @protect()
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
+        log_to_statsd=False,
+    )
+    def delete(self, pk: int) -> Response:
+        """Delete a semantic view.
+        ---
+        delete:
+          summary: Delete a semantic view
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          responses:
+            200:
+              description: Semantic view deleted
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        try:
+            DeleteSemanticViewCommand(pk).run()
+            return self.response(200, message="OK")
+        except SemanticViewNotFoundError:
+            return self.response_404()
+        except SemanticViewDeleteFailedError as ex:
+            logger.error(
+                "Error deleting semantic view: %s",
+                str(ex),
+                exc_info=True,
+            )
+            return self.response_422(message=str(ex))
+
 
 class SemanticLayerRestApi(BaseSupersetApi):
     resource_name = "semantic_layer"
@@ -376,6 +505,67 @@ class SemanticLayerRestApi(BaseSupersetApi):
 
         return self.response(200, result=schema)
 
+    @expose("/<uuid>/views", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    def views(self, uuid: str) -> FlaskResponse:
+        """List available views from a semantic layer.
+        ---
+        post:
+          summary: List available views from a semantic layer
+          parameters:
+          - in: path
+            schema:
+              type: string
+            name: uuid
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  type: object
+                  properties:
+                    runtime_data:
+                      type: object
+          responses:
+            200:
+              description: Available views
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        layer = SemanticLayerDAO.find_by_uuid(uuid)
+        if not layer:
+            return self.response_404()
+
+        body = request.get_json(silent=True) or {}
+        runtime_data = body.get("runtime_data", {})
+
+        try:
+            views = layer.implementation.get_semantic_views(runtime_data)
+        except Exception as ex:  # pylint: disable=broad-except
+            return self.response_400(message=str(ex))
+
+        # Check which views already exist with the same runtime config
+        existing = SemanticViewDAO.find_by_semantic_layer(str(layer.uuid))
+        existing_keys: set[tuple[str, str]] = set()
+        for v in existing:
+            config = v.configuration
+            if isinstance(config, str):
+                config = json.loads(config)
+            existing_keys.add((v.name, json.dumps(config or {}, sort_keys=True)))
+        runtime_key = json.dumps(runtime_data or {}, sort_keys=True)
+
+        result = [
+            {
+                "name": v.name,
+                "already_added": (v.name, runtime_key) in existing_keys,
+            }
+            for v in sorted(views, key=lambda v: v.name)
+        ]
+        return self.response(200, result=result)
+
     @expose("/", methods=("POST",))
     @protect()
     @safe
diff --git a/superset/semantic_layers/schemas.py b/superset/semantic_layers/schemas.py
index d10e0fd28fb..c0d835a7354 100644
--- a/superset/semantic_layers/schemas.py
+++ b/superset/semantic_layers/schemas.py
@@ -35,3 +35,11 @@ class SemanticLayerPutSchema(Schema):
     description = fields.String(allow_none=True)
     configuration = fields.Dict()
     cache_timeout = fields.Integer(allow_none=True)
+
+
+class SemanticViewPostSchema(Schema):
+    name = fields.String(required=True)
+    semantic_layer_uuid = fields.String(required=True)
+    configuration = fields.Dict(load_default=dict)
+    description = fields.String(allow_none=True)
+    cache_timeout = fields.Integer(allow_none=True)
diff --git a/tests/unit_tests/commands/semantic_layer/create_test.py b/tests/unit_tests/commands/semantic_layer/create_test.py
index ca4bc0f82a2..e531a1cec0d 100644
--- a/tests/unit_tests/commands/semantic_layer/create_test.py
+++ b/tests/unit_tests/commands/semantic_layer/create_test.py
@@ -147,3 +147,84 @@ def test_create_semantic_layer_copies_data(mocker: MockerFixture) -> None:
         "type": "snowflake",
         "configuration": {"account": "test"},
     }
+
+
+def test_create_semantic_view_success(mocker: MockerFixture) -> None:
+    """Test successful creation of a semantic view."""
+    mock_layer = MagicMock()
+    dao_layer = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao_layer.find_by_uuid.return_value = mock_layer
+
+    dao_view = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticViewDAO",
+    )
+    dao_view.validate_uniqueness.return_value = True
+    mock_model = MagicMock()
+    mock_model.uuid = "new-uuid"
+    mock_model.name = "orders"
+    dao_view.create.return_value = mock_model
+
+    from superset.commands.semantic_layer.create import CreateSemanticViewCommand
+
+    result = CreateSemanticViewCommand(
+        {
+            "name": "orders",
+            "semantic_layer_uuid": "layer-uuid",
+            "configuration": {"db": "prod"},
+        }
+    ).run()
+
+    assert result == mock_model
+    dao_view.validate_uniqueness.assert_called_once_with(
+        "orders", "layer-uuid", {"db": "prod"}
+    )
+
+
+def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
+    """Test CreateSemanticViewCommand raises when layer not found."""
+    dao_layer = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao_layer.find_by_uuid.return_value = None
+
+    mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticViewDAO",
+    )
+
+    from superset.commands.semantic_layer.create import CreateSemanticViewCommand
+    from superset.commands.semantic_layer.exceptions import (
+        SemanticLayerNotFoundError,
+    )
+
+    with pytest.raises(SemanticLayerNotFoundError):
+        CreateSemanticViewCommand({"name": "v", "semantic_layer_uuid": "missing"}).run()
+
+
+def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
+    """Test CreateSemanticViewCommand raises on duplicate view."""
+    mock_layer = MagicMock()
+    dao_layer = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticLayerDAO",
+    )
+    dao_layer.find_by_uuid.return_value = mock_layer
+
+    dao_view = mocker.patch(
+        "superset.commands.semantic_layer.create.SemanticViewDAO",
+    )
+    dao_view.validate_uniqueness.return_value = False
+
+    from superset.commands.semantic_layer.create import CreateSemanticViewCommand
+    from superset.commands.semantic_layer.exceptions import (
+        SemanticViewCreateFailedError,
+    )
+
+    with pytest.raises(SemanticViewCreateFailedError):
+        CreateSemanticViewCommand(
+            {
+                "name": "orders",
+                "semantic_layer_uuid": "layer-uuid",
+                "configuration": {"db": "prod"},
+            }
+        ).run()
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 456af8e390a..288f22710fc 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -48,3 +48,36 @@ def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
 
     with pytest.raises(SemanticLayerNotFoundError):
         DeleteSemanticLayerCommand("missing-uuid").run()
+
+
+def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
+    """Test successful deletion of a semantic view."""
+    mock_model = MagicMock()
+
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticViewDAO",
+    )
+    dao.find_by_id.return_value = mock_model
+
+    from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
+
+    DeleteSemanticViewCommand(42).run()
+
+    dao.find_by_id.assert_called_once_with(42, id_column="id")
+    dao.delete.assert_called_once_with([mock_model])
+
+
+def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
+    """Test that SemanticViewNotFoundError is raised when view is missing."""
+    dao = mocker.patch(
+        "superset.commands.semantic_layer.delete.SemanticViewDAO",
+    )
+    dao.find_by_id.return_value = None
+
+    from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
+    from superset.commands.semantic_layer.exceptions import (
+        SemanticViewNotFoundError,
+    )
+
+    with pytest.raises(SemanticViewNotFoundError):
+        DeleteSemanticViewCommand(999).run()


(superset) 19/25: Fix header name

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 07712fbd20d22292f91b234c617100aceed52178
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 14:27:48 2026 -0400

    Fix header name
---
 superset-frontend/src/features/home/Menu.tsx       |  13 ++-
 superset-frontend/src/pages/DatabaseList/index.tsx |   2 +-
 superset/initialization/__init__.py                |   3 +-
 superset/utils/semantic_layer_labels.py            | 105 +++++++++++++++++++++
 4 files changed, 120 insertions(+), 3 deletions(-)

diff --git a/superset-frontend/src/features/home/Menu.tsx b/superset-frontend/src/features/home/Menu.tsx
index b7eacb65cf4..bea7e32717a 100644
--- a/superset-frontend/src/features/home/Menu.tsx
+++ b/superset-frontend/src/features/home/Menu.tsx
@@ -34,6 +34,7 @@ import {
   MenuObjectProps,
   MenuData,
 } from 'src/types/bootstrapTypes';
+import { datasetsLabel } from 'src/utils/semanticLayerLabels';
 import RightMenu from './RightMenu';
 import { NAVBAR_MENU_POPUP_OFFSET } from './commonMenuData';
 
@@ -221,7 +222,7 @@ export function Menu({
         setActiveTabs(['Charts']);
         break;
       case path.startsWith(Paths.Datasets):
-        setActiveTabs(['Datasets']);
+        setActiveTabs([datasetsLabel()]);
         break;
       case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries):
         setActiveTabs(['SQL']);
@@ -399,6 +400,12 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
     Manage: true,
   };
 
+  // Remap labels that depend on feature flags so they stay in sync with
+  // the active-tab key used in the Menu component above.
+  const labelOverrides: Record<string, () => string> = {
+    Datasets: datasetsLabel,
+  };
+
   // Cycle through menu.menu to build out cleanedMenu and settings
   const cleanedMenu: MenuObjectProps[] = [];
   const settings: MenuObjectProps[] = [];
@@ -410,6 +417,10 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
     const children: (MenuObjectProps | string)[] = [];
     const newItem = {
       ...item,
+      // Apply any label override for this item (keyed by FAB internal name).
+      ...(item.name && labelOverrides[item.name]
+        ? { label: labelOverrides[item.name]() }
+        : {}),
     };
 
     // Filter childs
diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx
index c428d84b229..1722d5dbc8b 100644
--- a/superset-frontend/src/pages/DatabaseList/index.tsx
+++ b/superset-frontend/src/pages/DatabaseList/index.tsx
@@ -842,7 +842,7 @@ function DatabaseList({
         operator: FilterOperator.Equals,
         unfilteredLabel: t('All'),
         selects: [
-          { label: databaseLabel(), value: 'database' },
+          { label: t('Database'), value: 'database' },
           { label: t('Semantic Layer'), value: 'semantic_layer' },
         ],
       });
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index c8ec9bbe516..ba096bdd23a 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -66,6 +66,7 @@ from superset.superset_typing import FlaskResponse
 from superset.utils.core import is_test, pessimistic_connection_handling
 from superset.utils.decorators import transaction
 from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value
+from superset.utils.semantic_layer_labels import database_connections_menu_label
 
 if TYPE_CHECKING:
     from superset.app import SupersetApp
@@ -308,7 +309,7 @@ class SupersetAppInitializer:  # pylint: disable=too-many-public-methods
         appbuilder.add_view(
             DatabaseView,
             "Databases",
-            label=_("Database Connections"),
+            label=database_connections_menu_label(),
             icon="fa-database",
             category="Data",
             category_label=_("Data"),
diff --git a/superset/utils/semantic_layer_labels.py b/superset/utils/semantic_layer_labels.py
new file mode 100644
index 00000000000..8e690973209
--- /dev/null
+++ b/superset/utils/semantic_layer_labels.py
@@ -0,0 +1,105 @@
+# 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.
+
+"""
+Label helpers for SEMANTIC_LAYERS feature flag.
+
+When the SEMANTIC_LAYERS feature flag is enabled the UI broadens
+"dataset" → "datasource" and "database" → "data connection" so
+that semantic views and semantic layers feel like first-class
+citizens alongside traditional datasets and database connections.
+
+Mirror of superset-frontend/src/utils/semanticLayerLabels.ts.
+"""
+
+from __future__ import annotations
+
+from flask_babel import lazy_gettext as _
+
+
+def _sl(legacy: str, semantic: str) -> str:
+    """Return *semantic* when SEMANTIC_LAYERS is enabled, else *legacy*."""
+    # Imported lazily to avoid a circular import at module load time
+    # (superset.utils.semantic_layer_labels is imported by superset.initialization,
+    # which is itself imported during superset package initialization).
+    from superset import feature_flag_manager  # pylint: disable=import-outside-toplevel
+
+    return (
+        semantic
+        if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS")
+        else legacy
+    )
+
+
+# ---------------------------------------------------------------------------
+# "dataset" family
+# ---------------------------------------------------------------------------
+
+
+def dataset_label() -> str:
+    """Capitalized singular: "Dataset" / "Datasource" """
+    return _sl(_("Dataset"), _("Datasource"))
+
+
+def dataset_label_lower() -> str:
+    """Lower-case singular: "dataset" / "datasource" """
+    return _sl(_("dataset"), _("datasource"))
+
+
+def datasets_label() -> str:
+    """Capitalized plural: "Datasets" / "Datasources" """
+    return _sl(_("Datasets"), _("Datasources"))
+
+
+def datasets_label_lower() -> str:
+    """Lower-case plural: "datasets" / "datasources" """
+    return _sl(_("datasets"), _("datasources"))
+
+
+# ---------------------------------------------------------------------------
+# "database" family
+# ---------------------------------------------------------------------------
+
+
+def database_label() -> str:
+    """Capitalized singular: "Database" / "Data connection" """
+    return _sl(_("Database"), _("Data connection"))
+
+
+def database_label_lower() -> str:
+    """Lower-case singular: "database" / "data connection" """
+    return _sl(_("database"), _("data connection"))
+
+
+def databases_label() -> str:
+    """Capitalized plural: "Databases" / "Data connections" """
+    return _sl(_("Databases"), _("Data connections"))
+
+
+def databases_label_lower() -> str:
+    """Lower-case plural: "databases" / "data connections" """
+    return _sl(_("databases"), _("data connections"))
+
+
+# ---------------------------------------------------------------------------
+# Menu label (includes the word "Connections")
+# ---------------------------------------------------------------------------
+
+
+def database_connections_menu_label() -> str:
+    """Menu entry label: "Database Connections" / "Data Connections" """
+    return _sl(_("Database Connections"), _("Data Connections"))


(superset) 18/25: Fix data connection name

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 580243d84a48df2feaafc77c43f6e78d26f060aa
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 13:40:56 2026 -0400

    Fix data connection name
---
 .../superset-ui-core/src/query/types/Datasource.ts        |  7 +++++++
 superset-frontend/src/components/Chart/Chart.tsx          |  4 +++-
 superset-frontend/src/explore/types.ts                    |  2 ++
 superset/connectors/sqla/models.py                        |  1 +
 superset/semantic_layers/models.py                        |  1 +
 superset/superset_typing.py                               |  1 +
 tests/unit_tests/semantic_layers/models_test.py           | 15 +++++++++++++++
 7 files changed, 30 insertions(+), 1 deletion(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts
index aefe05db447..1341c334e03 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Datasource.ts
@@ -41,6 +41,13 @@ export interface Datasource {
   id: number;
   name: string;
   type: DatasourceType;
+  /**
+   * The parent resource that owns this datasource.
+   * For SQL-based datasets this is the database; for semantic views it is the
+   * semantic layer.  Use this field instead of the legacy `database` field when
+   * you only need the display name.
+   */
+  parent?: { name: string };
   columns: Column[];
   metrics: Metric[];
   description?: string;
diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx
index a7e38aa61a4..7a0933b5d97 100644
--- a/superset-frontend/src/components/Chart/Chart.tsx
+++ b/superset-frontend/src/components/Chart/Chart.tsx
@@ -361,7 +361,9 @@ class Chart extends PureComponent<ChartProps, {}> {
       width,
     } = this.props;
 
-    const databaseName = datasource?.database?.name as string | undefined;
+    const databaseName =
+      datasource?.parent?.name ??
+      (datasource?.database?.name as string | undefined);
 
     const isLoading = chartStatus === 'loading';
     // Suppress spinner during auto-refresh to avoid visual flicker
diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts
index a032de3dd76..81222ff6ffa 100644
--- a/superset-frontend/src/explore/types.ts
+++ b/superset-frontend/src/explore/types.ts
@@ -71,6 +71,8 @@ export type OptionSortType = Partial<
 
 export type Datasource = Dataset & {
   database?: DatabaseObject;
+  /** The parent resource that owns this datasource (database or semantic layer). */
+  parent?: { name: string };
   datasource?: string;
   catalog?: string | null;
   schema?: string;
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index b7919bf77ba..8f7925c35f3 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -449,6 +449,7 @@ class BaseDatasource(
             "column_formats": self.column_formats,
             "description": self.description,
             "database": self.database.data,  # pylint: disable=no-member
+            "parent": {"name": self.database.data["name"]},  # pylint: disable=no-member
             "default_endpoint": self.default_endpoint,
             "filter_select": self.filter_select_enabled,  # TODO deprecate
             "filter_select_enabled": self.filter_select_enabled,
diff --git a/superset/semantic_layers/models.py b/superset/semantic_layers/models.py
index 77fe8e9ad15..173b841fa60 100644
--- a/superset/semantic_layers/models.py
+++ b/superset/semantic_layers/models.py
@@ -305,6 +305,7 @@ class SemanticView(AuditMixinNullable, Model):
                 for metric in self.implementation.get_metrics()
             ],
             "database": {},
+            "parent": {"name": self.semantic_layer.name},
             # UI features
             "verbose_map": {},
             "order_by_choices": [],
diff --git a/superset/superset_typing.py b/superset/superset_typing.py
index ef002ac86ba..39ff9ab1347 100644
--- a/superset/superset_typing.py
+++ b/superset/superset_typing.py
@@ -299,6 +299,7 @@ class ExplorableData(TypedDict, total=False):
     column_formats: dict[str, str | None]
     description: str | None
     database: dict[str, Any]
+    parent: dict[str, Any]
     default_endpoint: str | None
     filter_select: bool
     filter_select_enabled: bool
diff --git a/tests/unit_tests/semantic_layers/models_test.py b/tests/unit_tests/semantic_layers/models_test.py
index 3da941da355..40cb4e54335 100644
--- a/tests/unit_tests/semantic_layers/models_test.py
+++ b/tests/unit_tests/semantic_layers/models_test.py
@@ -560,12 +560,18 @@ def test_semantic_view_data(
     mock_metrics: list[Metric],
 ) -> None:
     """Test SemanticView data property."""
+    from superset.semantic_layers.models import SemanticLayer
+
+    layer = SemanticLayer()
+    layer.name = "My Semantic Layer"
+
     view = SemanticView()
     view.name = "Orders View"
     view.description = "View of order data"
     view.id = 1
     view.uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
     view.semantic_layer_uuid = uuid.UUID("87654321-4321-8765-4321-876543218765")
+    view.semantic_layer = layer
     view.cache_timeout = 3600
 
     with patch.object(
@@ -582,6 +588,8 @@ def test_semantic_view_data(
         assert data["name"] == "Orders View"
         assert data["description"] == "View of order data"
         assert data["cache_timeout"] == 3600
+        assert data["database"] == {}
+        assert data["parent"] == {"name": "My Semantic Layer"}
 
         # Check columns
         assert len(data["columns"]) == 2
@@ -637,11 +645,18 @@ def test_semantic_view_data_for_slices(
     mock_metrics: list[Metric],
 ) -> None:
     """Test SemanticView data_for_slices returns same as data."""
+    from superset.semantic_layers.models import SemanticLayer
+
+    layer = SemanticLayer()
+    layer.name = "My Semantic Layer"
+
     view = SemanticView()
     view.name = "Orders View"
     view.description = "View of order data"
+    view.id = 1
     view.uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
     view.semantic_layer_uuid = uuid.UUID("87654321-4321-8765-4321-876543218765")
+    view.semantic_layer = layer
     view.cache_timeout = 3600
 
     with patch.object(


(superset) 09/25: Improve design

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit fc5a3cd32be268f2e8f2eb57eaeb23ffe5415f53
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Mar 11 17:15:55 2026 -0400

    Improve design
---
 .../features/semanticLayers/jsonFormsHelpers.tsx   |  48 +-
 .../semanticViews/AddSemanticViewModal.tsx         | 561 +++++++++++----------
 2 files changed, 345 insertions(+), 264 deletions(-)

diff --git a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
index bc5c5f54cca..6fbdb8ea283 100644
--- a/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
+++ b/superset-frontend/src/features/semanticLayers/jsonFormsHelpers.tsx
@@ -72,10 +72,53 @@ function ConstControl({ data, handleChange, path, schema }: ControlProps) {
 }
 const ConstRenderer = withJsonFormsControlProps(ConstControl);
 const constEntry = {
-  tester: rankWith(10, schemaMatches(s => s !== undefined && 'const' in s)),
+  tester: rankWith(
+    10,
+    schemaMatches(
+      s =>
+        s !== undefined &&
+        'const' in s &&
+        !(s as Record<string, unknown>).readOnly,
+    ),
+  ),
   renderer: ConstRenderer,
 };
 
+/**
+ * Renderer for read-only fields (e.g. a fixed database that the admin locked).
+ * Renders a disabled input showing the current value. Also ensures the default
+ * value is injected into form data (like ConstControl does for hidden fields).
+ */
+function ReadOnlyControl({
+  data,
+  handleChange,
+  path,
+  schema,
+  ...rest
+}: ControlProps) {
+  const defaultValue =
+    (schema as Record<string, unknown>).const ??
+    (schema as Record<string, unknown>).default;
+  useEffect(() => {
+    if (defaultValue !== undefined && data !== defaultValue) {
+      handleChange(path, defaultValue);
+    }
+  }, [defaultValue, data, handleChange, path]);
+
+  return TextControl({ ...rest, data, handleChange, path, schema, enabled: false });
+}
+const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl);
+const readOnlyEntry = {
+  tester: rankWith(
+    11,
+    schemaMatches(
+      s =>
+        s !== undefined && (s as Record<string, unknown>).readOnly === true,
+    ),
+  ),
+  renderer: ReadOnlyRenderer,
+};
+
 /**
  * Checks whether all dependency values are filled (non-empty).
  * Handles nested objects (like auth) by checking they have at least one key.
@@ -138,6 +181,7 @@ export const renderers = [
   ...rendererRegistryEntries,
   passwordEntry,
   constEntry,
+  readOnlyEntry,
   dynamicFieldEntry,
 ];
 
@@ -163,7 +207,7 @@ export function sanitizeSchema(schema: JsonSchema): JsonSchema {
       properties[key] = prop as JsonSchema;
     }
   }
-  return { ...schema, properties };
+  return { ...schema, properties } as JsonSchema;
 }
 
 /**
diff --git a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
index f7ee1b110c2..16c16f178d4 100644
--- a/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
+++ b/superset-frontend/src/features/semanticViews/AddSemanticViewModal.tsx
@@ -20,7 +20,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
 import { t } from '@apache-superset/core/translation';
 import { styled } from '@apache-superset/core/theme';
 import { SupersetClient } from '@superset-ui/core';
-import { Checkbox, Spin } from 'antd';
+import { Spin } from 'antd';
 import { Select } from '@superset-ui/core/components';
 import { Icons } from '@superset-ui/core/components/Icons';
 import { JsonForms } from '@jsonforms/react';
@@ -31,7 +31,6 @@ import {
   StandardModal,
   ModalFormField,
   MODAL_STANDARD_WIDTH,
-  MODAL_MEDIUM_WIDTH,
 } from 'src/components/Modal';
 import {
   renderers,
@@ -43,8 +42,6 @@ import {
   SCHEMA_REFRESH_DEBOUNCE_MS,
 } from 'src/features/semanticLayers/jsonFormsHelpers';
 
-type Step = 'layer' | 'configure' | 'select';
-
 interface SemanticLayerOption {
   uuid: string;
   name: string;
@@ -59,33 +56,57 @@ const ModalContent = styled.div`
   padding: ${({ theme }) => theme.sizeUnit * 4}px;
 `;
 
-const BackLink = styled.button`
-  background: none;
-  border: none;
-  color: ${({ theme }) => theme.colorPrimary};
-  cursor: pointer;
-  padding: 0;
-  font-size: ${({ theme }) => theme.fontSize[1]}px;
+const LoadingContainer = styled.div`
+  display: flex;
+  justify-content: center;
+  padding: ${({ theme }) => theme.sizeUnit * 4}px;
+`;
+
+const SectionLabel = styled.div`
+  color: ${({ theme }) => theme.colorText};
+  font-size: ${({ theme }) => theme.fontSize}px;
+  margin-top: ${({ theme }) => theme.sizeUnit}px;
   margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
-  display: inline-flex;
-  align-items: center;
-  gap: ${({ theme }) => theme.sizeUnit}px;
+`;
 
-  &:hover {
-    text-decoration: underline;
+const VerticalFormFields = styled.div`
+  margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
+
+  /* The antd renderer's VerticalLayout creates its own <Form> —
+     force flex-column so gap controls spacing between fields */
+  && form {
+    display: flex;
+    flex-direction: column;
+    gap: ${({ theme }) => theme.sizeUnit * 4}px;
   }
-`;
 
-const ViewList = styled.div`
-  display: flex;
-  flex-direction: column;
-  gap: ${({ theme }) => theme.sizeUnit}px;
-`;
+  /* Reset antd default margins so gap controls all spacing */
+  && .ant-form-item {
+    margin-bottom: 0;
+  }
 
-const LoadingContainer = styled.div`
-  display: flex;
-  justify-content: center;
-  padding: ${({ theme }) => theme.sizeUnit * 6}px;
+  /* Override ant-form-item-horizontal: stack label above control */
+  && .ant-form-item-row {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  && .ant-form-item-label {
+    text-align: left;
+    max-width: 100%;
+    flex: none;
+    padding-bottom: ${({ theme }) => theme.sizeUnit}px;
+  }
+
+  && .ant-form-item-control {
+    max-width: 100%;
+    flex: auto;
+  }
+
+  && .ant-form-item-label > label {
+    color: ${({ theme }) => theme.colorText};
+    font-size: ${({ theme }) => theme.fontSize}px;
+  }
 `;
 
 interface AddSemanticViewModalProps {
@@ -103,34 +124,43 @@ export default function AddSemanticViewModal({
   addDangerToast,
   addSuccessToast,
 }: AddSemanticViewModalProps) {
-  const [step, setStep] = useState<Step>('layer');
+  // --- Layer ---
   const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
   const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
     null,
   );
-  const [loading, setLoading] = useState(false);
-  const [saving, setSaving] = useState(false);
+  const [loadingLayers, setLoadingLayers] = useState(false);
 
-  // Step 2: Configure (runtime schema)
+  // --- Runtime config ---
   const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
   const [runtimeUiSchema, setRuntimeUiSchema] = useState<
     UISchemaElement | undefined
-  >(undefined);
+  >();
   const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
+  const [loadingRuntime, setLoadingRuntime] = useState(false);
   const [refreshingSchema, setRefreshingSchema] = useState(false);
-  const [hasRuntimeErrors, setHasRuntimeErrors] = useState(false);
   const errorsRef = useRef<ErrorObject[]>([]);
-  const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
-  const lastDepSnapshotRef = useRef<string>('');
   const dynamicDepsRef = useRef<Record<string, string[]>>({});
+  const lastDepSnapshotRef = useRef('');
+  const schemaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
-  // Step 3: Select views
+  // --- Views ---
   const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
-  const [selectedViews, setSelectedViews] = useState<Set<string>>(new Set());
+  const [selectedViewNames, setSelectedViewNames] = useState<string[]>([]);
   const [loadingViews, setLoadingViews] = useState(false);
+  const viewsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const lastViewsKeyRef = useRef('');
+
+  // --- Misc ---
+  const [saving, setSaving] = useState(false);
+  const fetchGenRef = useRef(0);
+
+  // =========================================================================
+  // Fetch helpers
+  // =========================================================================
 
   const fetchLayers = async () => {
-    setLoading(true);
+    setLoadingLayers(true);
     try {
       const { json } = await SupersetClient.get({
         endpoint: '/api/v1/semantic_layer/',
@@ -144,29 +174,27 @@ export default function AddSemanticViewModal({
     } catch {
       addDangerToast(t('An error occurred while fetching semantic layers'));
     } finally {
-      setLoading(false);
+      setLoadingLayers(false);
     }
   };
 
   const fetchViews = useCallback(
-    async (uuid: string, rData: Record<string, unknown>) => {
+    async (uuid: string, rData: Record<string, unknown>, gen: number) => {
       setLoadingViews(true);
-      setStep('select');
+      setAvailableViews([]);
+      setSelectedViewNames([]);
       try {
         const { json } = await SupersetClient.post({
           endpoint: `/api/v1/semantic_layer/${uuid}/views`,
           jsonPayload: { runtime_data: rData },
         });
-        const views: AvailableView[] = json.result ?? [];
-        setAvailableViews(views);
-        // Pre-select views that are already added (disabled anyway)
-        setSelectedViews(
-          new Set(views.filter(v => v.already_added).map(v => v.name)),
-        );
+        if (gen !== fetchGenRef.current) return;
+        setAvailableViews(json.result ?? []);
       } catch {
+        if (gen !== fetchGenRef.current) return;
         addDangerToast(t('An error occurred while fetching available views'));
       } finally {
-        setLoadingViews(false);
+        if (gen === fetchGenRef.current) setLoadingViews(false);
       }
     },
     [addDangerToast],
@@ -179,134 +207,194 @@ export default function AddSemanticViewModal({
     dynamicDepsRef.current = getDynamicDependencies(rawSchema);
   }, []);
 
-  const fetchRuntimeSchema = useCallback(
-    async (uuid: string, currentRuntimeData?: Record<string, unknown>) => {
-      const isInitialFetch = !currentRuntimeData;
-      if (isInitialFetch) setLoading(true);
-      else setRefreshingSchema(true);
+  const scheduleFetchViews = useCallback(
+    (uuid: string, data: Record<string, unknown>) => {
+      const key = JSON.stringify(data);
+      if (key === lastViewsKeyRef.current) return;
+      lastViewsKeyRef.current = key;
+      if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
+      viewsTimerRef.current = setTimeout(() => {
+        fetchViews(uuid, data, fetchGenRef.current);
+      }, SCHEMA_REFRESH_DEBOUNCE_MS);
+    },
+    [fetchViews],
+  );
+
+  // =========================================================================
+  // Layer change — fetch runtime schema, clear downstream state
+  // =========================================================================
+
+  const handleLayerChange = useCallback(
+    async (uuid: string) => {
+      fetchGenRef.current += 1;
+      const gen = fetchGenRef.current;
+
+      setSelectedLayerUuid(uuid);
+      if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
+      if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
+      setRuntimeSchema(null);
+      setRuntimeUiSchema(undefined);
+      setRuntimeData({});
+      errorsRef.current = [];
+      dynamicDepsRef.current = {};
+      lastDepSnapshotRef.current = '';
+      setAvailableViews([]);
+      setSelectedViewNames([]);
+      lastViewsKeyRef.current = '';
+
+      setLoadingRuntime(true);
       try {
         const { json } = await SupersetClient.post({
           endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
-          jsonPayload: currentRuntimeData
-            ? { runtime_data: currentRuntimeData }
-            : {},
+          jsonPayload: {},
         });
+        if (gen !== fetchGenRef.current) return;
         const schema = json.result;
         if (
-          isInitialFetch &&
-          (!schema ||
-            !schema.properties ||
-            Object.keys(schema.properties).length === 0)
+          !schema?.properties ||
+          Object.keys(schema.properties).length === 0
         ) {
-          // No runtime config needed — skip to step 3
-          fetchViews(uuid, {});
-        } else if (isInitialFetch) {
-          applyRuntimeSchema(schema);
-          setStep('configure');
+          // No runtime config needed — fetch views right away
+          fetchViews(uuid, {}, gen);
         } else {
           applyRuntimeSchema(schema);
         }
       } catch {
-        if (isInitialFetch) {
-          addDangerToast(
-            t('An error occurred while fetching the runtime schema'),
-          );
-        }
+        if (gen !== fetchGenRef.current) return;
+        addDangerToast(
+          t('An error occurred while fetching the runtime schema'),
+        );
       } finally {
-        if (isInitialFetch) setLoading(false);
-        else setRefreshingSchema(false);
+        if (gen === fetchGenRef.current) setLoadingRuntime(false);
+      }
+    },
+    [applyRuntimeSchema, fetchViews, addDangerToast],
+  );
+
+  // =========================================================================
+  // Runtime form change — refresh dynamic fields or auto-fetch views
+  // =========================================================================
+
+  const handleRuntimeFormChange = useCallback(
+    ({
+      data,
+      errors,
+    }: {
+      data: Record<string, unknown>;
+      errors?: ErrorObject[];
+    }) => {
+      setRuntimeData(data);
+      errorsRef.current = errors ?? [];
+
+      if (!selectedLayerUuid) return;
+      const gen = fetchGenRef.current;
+
+      // Dynamic deps changed → refresh schema (e.g. database → schema)
+      const dynamicDeps = dynamicDepsRef.current;
+      if (Object.keys(dynamicDeps).length > 0) {
+        const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
+          areDependenciesSatisfied(deps, data),
+        );
+        if (hasSatisfiedDeps) {
+          const snapshot = serializeDependencyValues(dynamicDeps, data);
+          if (snapshot !== lastDepSnapshotRef.current) {
+            lastDepSnapshotRef.current = snapshot;
+            // Config is changing — clear views
+            setAvailableViews([]);
+            setSelectedViewNames([]);
+            lastViewsKeyRef.current = '';
+            if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
+            const uuid = selectedLayerUuid;
+            schemaTimerRef.current = setTimeout(async () => {
+              setRefreshingSchema(true);
+              try {
+                const { json } = await SupersetClient.post({
+                  endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
+                  jsonPayload: { runtime_data: data },
+                });
+                if (gen !== fetchGenRef.current) return;
+                applyRuntimeSchema(json.result);
+              } catch {
+                // Silent fail on refresh — form still works
+              } finally {
+                if (gen === fetchGenRef.current) setRefreshingSchema(false);
+              }
+            }, SCHEMA_REFRESH_DEBOUNCE_MS);
+            return;
+          }
+        }
+      }
+
+      // No schema refresh needed — fetch views if form is valid
+      if (errorsRef.current.length === 0) {
+        scheduleFetchViews(selectedLayerUuid, data);
       }
     },
-    [addDangerToast, applyRuntimeSchema, fetchViews],
+    [selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews],
   );
 
-  // Reset state when modal closes
+  // After a schema refresh settles, JSON Forms re-validates and fires
+  // onChange → handleRuntimeFormChange handles view fetching. As a fallback
+  // (in case onChange doesn't fire), try once refreshingSchema flips false.
+  const prevRefreshingRef = useRef(false);
+  useEffect(() => {
+    if (prevRefreshingRef.current && !refreshingSchema && selectedLayerUuid) {
+      const timer = setTimeout(() => {
+        if (errorsRef.current.length === 0) {
+          scheduleFetchViews(selectedLayerUuid, runtimeData);
+        }
+      }, 100);
+      prevRefreshingRef.current = false;
+      return () => clearTimeout(timer);
+    }
+    prevRefreshingRef.current = refreshingSchema;
+    return undefined;
+  }, [refreshingSchema, selectedLayerUuid, runtimeData, scheduleFetchViews]);
+
+  // =========================================================================
+  // Modal open / close
+  // =========================================================================
+
   useEffect(() => {
     if (show) {
       fetchLayers();
     } else {
-      setStep('layer');
+      fetchGenRef.current += 1;
+      if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
+      if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
       setLayers([]);
       setSelectedLayerUuid(null);
-      setLoading(false);
-      setSaving(false);
+      setLoadingLayers(false);
       setRuntimeSchema(null);
       setRuntimeUiSchema(undefined);
       setRuntimeData({});
+      setLoadingRuntime(false);
       setRefreshingSchema(false);
-      setHasRuntimeErrors(false);
       errorsRef.current = [];
-      lastDepSnapshotRef.current = '';
       dynamicDepsRef.current = {};
+      lastDepSnapshotRef.current = '';
       setAvailableViews([]);
-      setSelectedViews(new Set());
+      setSelectedViewNames([]);
       setLoadingViews(false);
-      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+      setSaving(false);
+      lastViewsKeyRef.current = '';
     }
   }, [show]); // eslint-disable-line react-hooks/exhaustive-deps
 
-  const maybeRefreshRuntimeSchema = useCallback(
-    (data: Record<string, unknown>) => {
-      if (!selectedLayerUuid) return;
-
-      const dynamicDeps = dynamicDepsRef.current;
-      if (Object.keys(dynamicDeps).length === 0) return;
-
-      const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
-        areDependenciesSatisfied(deps, data),
-      );
-      if (!hasSatisfiedDeps) return;
-
-      const snapshot = serializeDependencyValues(dynamicDeps, data);
-      if (snapshot === lastDepSnapshotRef.current) return;
-      lastDepSnapshotRef.current = snapshot;
-
-      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
-      debounceTimerRef.current = setTimeout(() => {
-        fetchRuntimeSchema(selectedLayerUuid, data);
-      }, SCHEMA_REFRESH_DEBOUNCE_MS);
-    },
-    [selectedLayerUuid, fetchRuntimeSchema],
-  );
-
-  const handleRuntimeFormChange = useCallback(
-    ({
-      data,
-      errors,
-    }: {
-      data: Record<string, unknown>;
-      errors?: ErrorObject[];
-    }) => {
-      setRuntimeData(data);
-      errorsRef.current = errors ?? [];
-      setHasRuntimeErrors(errorsRef.current.length > 0);
-      maybeRefreshRuntimeSchema(data);
-    },
-    [maybeRefreshRuntimeSchema],
-  );
-
-  const handleToggleView = (viewName: string, checked: boolean) => {
-    setSelectedViews(prev => {
-      const next = new Set(prev);
-      if (checked) {
-        next.add(viewName);
-      } else {
-        next.delete(viewName);
-      }
-      return next;
-    });
-  };
+  // =========================================================================
+  // Save
+  // =========================================================================
 
   const newViewCount = availableViews.filter(
-    v => selectedViews.has(v.name) && !v.already_added,
+    v => selectedViewNames.includes(v.name) && !v.already_added,
   ).length;
 
-  const handleAddViews = async () => {
-    if (!selectedLayerUuid) return;
+  const handleSave = async () => {
+    if (!selectedLayerUuid || newViewCount === 0) return;
     setSaving(true);
     try {
       const viewsToCreate = availableViews
-        .filter(v => selectedViews.has(v.name) && !v.already_added)
+        .filter(v => selectedViewNames.includes(v.name) && !v.already_added)
         .map(v => ({
           name: v.name,
           semantic_layer_uuid: selectedLayerUuid,
@@ -328,148 +416,97 @@ export default function AddSemanticViewModal({
     }
   };
 
-  const handleSave = () => {
-    if (step === 'layer' && selectedLayerUuid) {
-      fetchRuntimeSchema(selectedLayerUuid);
-    } else if (step === 'configure' && selectedLayerUuid) {
-      fetchViews(selectedLayerUuid, runtimeData);
-    } else if (step === 'select') {
-      handleAddViews();
-    }
-  };
-
-  const handleBack = () => {
-    if (step === 'configure') {
-      setStep('layer');
-      setRuntimeSchema(null);
-      setRuntimeUiSchema(undefined);
-      setRuntimeData({});
-      setHasRuntimeErrors(false);
-      errorsRef.current = [];
-      lastDepSnapshotRef.current = '';
-      dynamicDepsRef.current = {};
-      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
-    } else if (step === 'select') {
-      // Go back to configure if we had a runtime schema, otherwise to layer
-      if (runtimeSchema) {
-        setStep('configure');
-      } else {
-        setStep('layer');
-      }
-      setAvailableViews([]);
-      setSelectedViews(new Set());
-    }
-  };
+  // =========================================================================
+  // Render
+  // =========================================================================
 
-  const title =
-    step === 'layer'
-      ? t('Add Semantic View')
-      : step === 'configure'
-        ? t('Configure')
-        : t('Select Views');
+  const hasRuntimeFields =
+    runtimeSchema?.properties &&
+    Object.keys(runtimeSchema.properties).length > 0;
 
-  const saveText =
-    step === 'select' ? t('Add %s view(s)', newViewCount) : t('Next');
-
-  const saveDisabled =
-    step === 'layer'
-      ? !selectedLayerUuid
-      : step === 'configure'
-        ? hasRuntimeErrors
-        : step === 'select'
-          ? newViewCount === 0 || saving
-          : false;
-
-  const modalWidth =
-    step === 'configure' ? MODAL_MEDIUM_WIDTH : MODAL_STANDARD_WIDTH;
+  const viewsDisabled =
+    loadingViews || (!loadingViews && availableViews.length === 0);
 
   return (
     <StandardModal
       show={show}
       onHide={onHide}
       onSave={handleSave}
-      title={title}
+      title={t('Add Semantic View')}
       icon={<Icons.PlusOutlined />}
-      width={modalWidth}
-      saveDisabled={saveDisabled}
-      saveText={saveText}
+      width={MODAL_STANDARD_WIDTH}
+      saveDisabled={newViewCount === 0 || saving}
+      saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
       saveLoading={saving}
-      contentLoading={loading}
     >
-      {step === 'layer' && (
-        <ModalContent>
-          <ModalFormField label={t('Semantic Layer')}>
+      <ModalContent>
+        {/* Semantic Layer */}
+        <ModalFormField label={t('Semantic Layer')}>
+          <Select
+            ariaLabel={t('Semantic layer')}
+            placeholder={t('Select a semantic layer')}
+            loading={loadingLayers}
+            value={selectedLayerUuid}
+            onChange={value => handleLayerChange(value as string)}
+            options={layers.map(l => ({
+              value: l.uuid,
+              label: l.name,
+            }))}
+            getPopupContainer={() => document.body}
+          />
+        </ModalFormField>
+
+        {/* Loading runtime schema */}
+        {loadingRuntime && (
+          <LoadingContainer>
+            <Spin size="small" />
+          </LoadingContainer>
+        )}
+
+        {/* Source location (runtime config fields) */}
+        {hasRuntimeFields && !loadingRuntime && (
+          <>
+            <SectionLabel>{t('Source location')}</SectionLabel>
+            <VerticalFormFields>
+              <JsonForms
+                schema={runtimeSchema!}
+                uischema={runtimeUiSchema}
+                data={runtimeData}
+                renderers={renderers}
+                cells={cellRegistryEntries}
+                config={{ refreshingSchema, formData: runtimeData }}
+                validationMode="ValidateAndHide"
+                onChange={handleRuntimeFormChange}
+              />
+            </VerticalFormFields>
+          </>
+        )}
+
+        {/* Semantic Views — always visible once a layer is selected */}
+        {selectedLayerUuid && !loadingRuntime && (
+          <ModalFormField label={t('Semantic Views')}>
             <Select
-              ariaLabel={t('Semantic layer')}
-              placeholder={t('Select a semantic layer')}
-              value={selectedLayerUuid}
-              onChange={value => setSelectedLayerUuid(value as string)}
-              options={layers.map(l => ({
-                value: l.uuid,
-                label: l.name,
-              }))}
+              ariaLabel={t('Semantic views')}
+              placeholder={t('Select semantic views')}
+              mode="multiple"
+              loading={loadingViews}
+              disabled={viewsDisabled}
+              value={selectedViewNames}
+              onChange={values => setSelectedViewNames(values as string[])}
+              options={availableViews
+                .sort((a, b) => a.name.localeCompare(b.name))
+                .map(v => ({
+                  value: v.name,
+                  label: v.already_added
+                    ? `${v.name} (${t('already added')})`
+                    : v.name,
+                  disabled: v.already_added,
+                }))}
               getPopupContainer={() => document.body}
-              dropdownAlign={{
-                points: ['tl', 'bl'],
-                offset: [0, 4],
-                overflow: { adjustX: 0, adjustY: 1 },
-              }}
             />
           </ModalFormField>
-        </ModalContent>
-      )}
-
-      {step === 'configure' && (
-        <ModalContent>
-          <BackLink type="button" onClick={handleBack}>
-            <Icons.CaretLeftOutlined iconSize="s" />
-            {t('Back')}
-          </BackLink>
-          {runtimeSchema && (
-            <JsonForms
-              schema={runtimeSchema}
-              uischema={runtimeUiSchema}
-              data={runtimeData}
-              renderers={renderers}
-              cells={cellRegistryEntries}
-              config={{ refreshingSchema, formData: runtimeData }}
-              validationMode="ValidateAndHide"
-              onChange={handleRuntimeFormChange}
-            />
-          )}
-        </ModalContent>
-      )}
-
-      {step === 'select' && (
-        <ModalContent>
-          <BackLink type="button" onClick={handleBack}>
-            <Icons.CaretLeftOutlined iconSize="s" />
-            {t('Back')}
-          </BackLink>
-          {loadingViews ? (
-            <LoadingContainer>
-              <Spin />
-            </LoadingContainer>
-          ) : (
-            <ViewList>
-              {availableViews.map(view => (
-                <Checkbox
-                  key={view.name}
-                  checked={selectedViews.has(view.name)}
-                  disabled={view.already_added}
-                  onChange={e => handleToggleView(view.name, e.target.checked)}
-                >
-                  {view.name}
-                  {view.already_added && <span> ({t('Already added')})</span>}
-                </Checkbox>
-              ))}
-              {availableViews.length === 0 && !loadingViews && (
-                <span>{t('No views available')}</span>
-              )}
-            </ViewList>
-          )}
-        </ModalContent>
-      )}
+        )}
+      </ModalContent>
     </StandardModal>
   );
 }


(superset) 21/25: Working on filters

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit ab52bd14c4c448c5ddab4e51bc151f5efb295ddd
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Apr 17 16:36:33 2026 -0400

    Working on filters
---
 .../src/components/ListView/Filters/index.tsx      |  6 ++
 .../src/components/ListView/ListView.tsx           | 18 +++-
 superset-frontend/src/pages/DatasetList/index.tsx  | 95 +++++++++++++++++++++-
 3 files changed, 115 insertions(+), 4 deletions(-)

diff --git a/superset-frontend/src/components/ListView/Filters/index.tsx b/superset-frontend/src/components/ListView/Filters/index.tsx
index c32d2393b21..aca1fb16750 100644
--- a/superset-frontend/src/components/ListView/Filters/index.tsx
+++ b/superset-frontend/src/components/ListView/Filters/index.tsx
@@ -60,6 +60,12 @@ function UIFilters(
         filter.current?.clearFilter?.();
       });
     },
+    clearFilterById: (id: string) => {
+      const index = filters.findIndex(f => f.id === id);
+      if (index >= 0) {
+        filterRefs[index]?.current?.clearFilter?.();
+      }
+    },
   }));
 
   return (
diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx
index 87135397554..6a3e60f852c 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -264,6 +264,11 @@ export interface ListViewProps<T extends object = any> {
   columnsForWrapText?: string[];
   enableBulkTag?: boolean;
   bulkTagResourceName?: string;
+  /** Optional ref exposed to callers for programmatic filter control. */
+  filtersRef?: React.RefObject<{
+    clearFilters: () => void;
+    clearFilterById: (id: string) => void;
+  }>;
 }
 
 export function ListView<T extends object = any>({
@@ -290,6 +295,7 @@ export function ListView<T extends object = any>({
   columnsForWrapText,
   enableBulkTag = false,
   bulkTagResourceName,
+  filtersRef,
   addSuccessToast,
   addDangerToast,
 }: ListViewProps<T>) {
@@ -337,7 +343,17 @@ export function ListView<T extends object = any>({
     });
   }
 
-  const filterControlsRef = useRef<{ clearFilters: () => void }>(null);
+  const filterControlsRef = useRef<{
+    clearFilters: () => void;
+    clearFilterById: (id: string) => void;
+  }>(null);
+
+  // Wire the optional external filtersRef to our internal filterControlsRef.
+  useEffect(() => {
+    if (filtersRef) {
+      (filtersRef as any).current = filterControlsRef.current;
+    }
+  });
 
   const handleClearFilterControls = useCallback(() => {
     if (query.filters) {
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index b189e87759a..b9bb136cfcb 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -24,7 +24,7 @@ import {
   FeatureFlag,
 } from '@superset-ui/core';
 import { styled, useTheme, css } from '@apache-superset/core/theme';
-import { FunctionComponent, useState, useMemo, useCallback, Key } from 'react';
+import { FunctionComponent, useState, useMemo, useCallback, useRef, Key } from 'react';
 import type { CellProps } from 'react-table';
 import { Link, useHistory } from 'react-router-dom';
 import rison from 'rison';
@@ -194,7 +194,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
   const [loading, setLoading] = useState(true);
   const [lastFetchConfig, setLastFetchConfig] =
     useState<ListViewFetchDataConfig | null>(null);
-
   const currentSourceFilter = useMemo(() => {
     const sourceTypeFilter = lastFetchConfig?.filters.find(
       filter => filter.id === 'source_type',
@@ -209,6 +208,84 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     return (sourceTypeFilter?.value as string | undefined) ?? '';
   }, [lastFetchConfig]);
 
+  // Track the current type and connection filter values so cascade-clear logic
+  // can inspect them when a different filter changes.
+  const currentTypeFilter = useRef<unknown>(undefined);
+  const currentConnectionFilter = useRef<unknown>(undefined);
+
+  // Ref wired to ListView's filter controls for programmatic per-filter clearing.
+  const filtersRef = useRef<{
+    clearFilters: () => void;
+    clearFilterById: (id: string) => void;
+  }>(null);
+
+  /**
+   * Cascade-clear incompatible filters when one filter changes.
+   *
+   * Rules:
+   * - Selecting a DB connection → clear "Semantic View" type
+   * - Selecting a SL connection → clear "Physical" / "Virtual" type
+   * - Selecting Physical/Virtual type → clear any SL connection
+   * - Selecting Semantic View type → clear any DB connection
+   * - Selecting Source=Database → clear SL connection + Semantic View type
+   * - Selecting Source=Semantic Layer → clear DB connection + Physical/Virtual type
+   */
+  const cascadeClear = useCallback(
+    (
+      changed: 'source' | 'type' | 'connection',
+      newValue: unknown,
+    ) => {
+      if (!isFeatureEnabled(FeatureFlag.SemanticLayers)) return;
+
+      const isSlConnection = (v: unknown) =>
+        typeof v === 'string' && v.startsWith('sl:');
+      const isDbConnection = (v: unknown) =>
+        v !== undefined && v !== null && v !== '' && !isSlConnection(v);
+      const isSemanticViewType = (v: unknown) => v === 'semantic_view';
+      const isPhysicalVirtualType = (v: unknown) =>
+        v === true || v === false;
+
+      if (changed === 'connection') {
+        if (isSlConnection(newValue) && isPhysicalVirtualType(currentTypeFilter.current)) {
+          filtersRef.current?.clearFilterById('sql');
+        }
+        if (isDbConnection(newValue) && isSemanticViewType(currentTypeFilter.current)) {
+          filtersRef.current?.clearFilterById('sql');
+        }
+      }
+
+      if (changed === 'type') {
+        if (isSemanticViewType(newValue) && isDbConnection(currentConnectionFilter.current)) {
+          filtersRef.current?.clearFilterById('database');
+        }
+        if (isPhysicalVirtualType(newValue) && isSlConnection(currentConnectionFilter.current)) {
+          filtersRef.current?.clearFilterById('database');
+        }
+      }
+
+      if (changed === 'source') {
+        const src = newValue as string;
+        if (src === 'database') {
+          if (isSemanticViewType(currentTypeFilter.current)) {
+            filtersRef.current?.clearFilterById('sql');
+          }
+          if (isSlConnection(currentConnectionFilter.current)) {
+            filtersRef.current?.clearFilterById('database');
+          }
+        }
+        if (src === 'semantic_layer') {
+          if (isPhysicalVirtualType(currentTypeFilter.current)) {
+            filtersRef.current?.clearFilterById('sql');
+          }
+          if (isDbConnection(currentConnectionFilter.current)) {
+            filtersRef.current?.clearFilterById('database');
+          }
+        }
+      }
+    },
+    [],
+  );
+
   /**
    * Fetches "Data connection" filter options — a combined list of databases
    * and semantic layers.
@@ -333,7 +410,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         otherFilters.push({
           col: 'database',
           opr: databaseFilter.operator,
-          value: raw,
+          value: raw as string | number,
         });
       }
     }
@@ -859,6 +936,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
                 { label: t('Database'), value: 'database' },
                 { label: t('Semantic Layer'), value: 'semantic_layer' },
               ],
+              onFilterUpdate: (option: any) => {
+                cascadeClear('source', option?.value);
+              },
             },
           ]
         : []),
@@ -889,6 +969,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
                   ? [{ label: t('Semantic View'), value: 'semantic_view' }]
                   : []),
               ],
+              onFilterUpdate: (option: any) => {
+                currentTypeFilter.current = option?.value;
+                cascadeClear('type', option?.value);
+              },
             },
           ]
         : [
@@ -915,6 +999,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         fetchSelects: fetchConnectionOptions,
         paginate: true,
         dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
+        onFilterUpdate: (option: any) => {
+          currentConnectionFilter.current = option?.value;
+          cascadeClear('connection', option?.value);
+        },
       },
       {
         Header: t('Schema'),
@@ -1381,6 +1469,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
               pageSize={PAGE_SIZE}
               fetchData={fetchData}
               filters={filterTypes}
+              filtersRef={filtersRef}
               loading={loading}
               initialSort={initialSort}
               bulkActions={bulkActions}


(superset) 02/25: Address comments

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit bc58d13c347896167a51c99380463e99604b0296
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Mar 18 12:39:03 2026 -0400

    Address comments
---
 superset/daos/semantic_layer.py | 8 ++++++--
 superset/semantic_layers/api.py | 6 ++++--
 2 files changed, 10 insertions(+), 4 deletions(-)

diff --git a/superset/daos/semantic_layer.py b/superset/daos/semantic_layer.py
index 8782973561c..9690a2fa12b 100644
--- a/superset/daos/semantic_layer.py
+++ b/superset/daos/semantic_layer.py
@@ -21,6 +21,8 @@ from __future__ import annotations
 
 from typing import Any
 
+from sqlalchemy.exc import StatementError
+
 from superset_core.semantic_layers.daos import (
     AbstractSemanticLayerDAO,
     AbstractSemanticViewDAO,
@@ -46,12 +48,14 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO):
                 .filter(SemanticLayer.uuid == uuid_str)
                 .one_or_none()
             )
-        except ValueError:
+        except (ValueError, StatementError):
             return None
 
     @classmethod
     def find_all(cls, skip_base_filter: bool = False) -> list[SemanticLayer]:
-        return db.session.query(SemanticLayer).all()
+        query = db.session.query(SemanticLayer)
+        query = cls._apply_base_filter(query, skip_base_filter)
+        return query.all()
 
     @classmethod
     def validate_uniqueness(cls, name: str) -> bool:
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index eb10205106a..3b5703808a2 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -163,6 +163,8 @@ class SemanticLayerRestApi(BaseSupersetApi):
     class_permission_name = "SemanticLayer"
     method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
     openapi_spec_tag = "Semantic Layers"
+    add_model_schema = SemanticLayerPostSchema()
+    edit_model_schema = SemanticLayerPutSchema()
 
     @expose("/types", methods=("GET",))
     @protect()
@@ -320,7 +322,7 @@ class SemanticLayerRestApi(BaseSupersetApi):
               $ref: '#/components/responses/422'
         """
         try:
-            item = SemanticLayerPostSchema().load(request.json)
+            item = self.add_model_schema.load(request.json)
         except ValidationError as error:
             return self.response_400(message=error.messages)
 
@@ -380,7 +382,7 @@ class SemanticLayerRestApi(BaseSupersetApi):
               $ref: '#/components/responses/422'
         """
         try:
-            item = SemanticLayerPutSchema().load(request.json)
+            item = self.edit_model_schema.load(request.json)
         except ValidationError as error:
             return self.response_400(message=error.messages)
 


(superset) 03/25: Update permissions

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

betodealmeida pushed a commit to branch backup/semantic-layer-rename-database-dataset-before-rebase-20260423-133321
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 469acf12b28b7da77033bb85aa5950b5a87c96af
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Wed Mar 18 17:26:47 2026 -0400

    Update permissions
---
 superset/semantic_layers/api.py | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index 3b5703808a2..acb278cdf13 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -161,7 +161,12 @@ class SemanticLayerRestApi(BaseSupersetApi):
     resource_name = "semantic_layer"
     allow_browser_login = True
     class_permission_name = "SemanticLayer"
-    method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
+    method_permission_name = {
+        **MODEL_API_RW_METHOD_PERMISSION_MAP,
+        "types": "read",
+        "configuration_schema": "read",
+        "runtime_schema": "read",
+    }
     openapi_spec_tag = "Semantic Layers"
     add_model_schema = SemanticLayerPostSchema()
     edit_model_schema = SemanticLayerPutSchema()