Compare commits

...

50 Commits

Author SHA1 Message Date
Beto Dealmeida
061706acb9 Fix edit dataset 2026-04-23 14:05:03 -04:00
Beto Dealmeida
6e8fa1c362 Small fixes 2026-04-23 14:05:03 -04:00
Beto Dealmeida
3fb965a72f Fix lint 2026-04-23 14:05:02 -04:00
Beto Dealmeida
0e2813eb2c Fix filters 2026-04-23 14:05:02 -04:00
Beto Dealmeida
047fe24e31 Working on filters 2026-04-23 14:05:02 -04:00
Beto Dealmeida
7193cbd097 Working on filters 2026-04-23 14:05:02 -04:00
Beto Dealmeida
1e682fb78a Fix header name 2026-04-23 14:05:02 -04:00
Beto Dealmeida
185a4232ad Fix data connection name 2026-04-23 14:05:02 -04:00
Beto Dealmeida
2224876f57 Small fixes 2026-04-23 14:05:02 -04:00
Beto Dealmeida
a817cc0dc7 Fix lint 2026-04-23 14:05:02 -04:00
Beto Dealmeida
b2082de97d Revert some aggressive renames 2026-04-23 14:05:02 -04:00
Beto Dealmeida
21f3c91567 More fixes 2026-04-23 14:05:02 -04:00
Beto Dealmeida
a0b7e4df89 chore: rename database/database when using semantic layers 2026-04-23 14:05:02 -04:00
Beto Dealmeida
7f38e07299 Small fixes 2026-04-23 14:05:02 -04:00
Beto Dealmeida
80f6ac0c78 Bulk delete 2026-04-23 14:05:02 -04:00
Beto Dealmeida
a36cee1207 Address comments 2026-04-23 14:05:02 -04:00
Beto Dealmeida
33920d2ca7 Improve design 2026-04-23 14:05:02 -04:00
Beto Dealmeida
bf339a6177 Fix lint/tests 2026-04-23 14:05:02 -04:00
Beto Dealmeida
cdcb5ecce4 Fix imports 2026-04-23 14:05:01 -04:00
Beto Dealmeida
68e6380d88 feat: CRUD for adding/deleting semantic views 2026-04-23 14:05:01 -04:00
Beto Dealmeida
0f08f016d2 Address semantic layer review nits
Improve semantic layer schema refresh error handling and connections endpoint behavior to reduce noisy failures while keeping this feature branch focused. Also restore frontend typing consistency and add debounce coverage for dynamic schema refresh.
2026-04-23 14:00:59 -04:00
Beto Dealmeida
65fb2ff834 Fix rebase 2026-04-23 13:33:47 -04:00
Beto Dealmeida
d659089c59 feat: UI for semantic layers 2026-04-23 13:33:47 -04:00
Beto Dealmeida
5e046a857c Update permissions 2026-04-23 13:33:47 -04:00
Beto Dealmeida
36554237aa Address comments 2026-04-23 13:33:47 -04:00
Beto Dealmeida
6f93e1cbb1 feat: API for semantic layers 2026-04-23 13:33:47 -04:00
Beto Dealmeida
913259299e Address comments 2026-04-23 13:26:40 -04:00
Beto Dealmeida
2351e0ead7 Move logic to commands 2026-04-16 18:16:23 -04:00
Beto Dealmeida
8c6f211003 Address comments 2026-04-16 18:16:23 -04:00
Beto Dealmeida
0e3d78817f Fix imports 2026-04-16 18:16:23 -04:00
Beto Dealmeida
f0c8304e24 feat: UI for semantic views 2026-04-16 18:16:23 -04:00
Beto Dealmeida
80233aed46 Fix DAO 2026-04-16 18:16:23 -04:00
Beto Dealmeida
6f350428df Check uniqueness 2026-04-16 18:16:23 -04:00
Beto Dealmeida
548ccfde44 feat: API for semantic views 2026-04-16 18:16:23 -04:00
Beto Dealmeida
596008203c Fix Datasource type 2026-04-16 18:16:23 -04:00
Beto Dealmeida
ff46c86df3 feat: Explore integration 2026-04-16 18:16:23 -04:00
Beto Dealmeida
4e30638024 Address more comments 2026-04-16 18:14:39 -04:00
Beto Dealmeida
efa9159cc8 Address comments 2026-04-16 11:22:44 -04:00
Beto Dealmeida
14668f37bd Improvements 2026-03-10 15:32:07 -04:00
Beto Dealmeida
27a2466855 feat: models and DAOs 2026-03-10 14:15:07 -04:00
Beto Dealmeida
e35c6946ec Fix lint/tests 2026-03-10 13:48:21 -04:00
Beto Dealmeida
12c5bfa0a5 Improve types 2026-03-10 12:56:09 -04:00
Beto Dealmeida
0303a234a3 Fix tests 2026-03-10 12:56:09 -04:00
Beto Dealmeida
09e9927652 Simplify 2026-03-10 12:56:09 -04:00
Beto Dealmeida
3f9ea361bb docs: add semantic layers to contribution types 2026-03-10 12:56:09 -04:00
Beto Dealmeida
f1047140ee feat: add @semantic_layer decorator for extension discovery 2026-03-10 12:56:09 -04:00
Beto Dealmeida
15e3ab4493 Address comments 2026-03-10 12:56:09 -04:00
Beto Dealmeida
755aa2e32f Address TODOs 2026-03-10 12:56:09 -04:00
Beto Dealmeida
17d1ed7353 chore: remove AdhocFilter 2026-03-10 12:56:09 -04:00
Beto Dealmeida
9c1bcb70d0 feat: semantic layer extension 2026-03-10 12:56:09 -04:00
106 changed files with 15300 additions and 497 deletions

View File

@@ -52,6 +52,7 @@ jobs:
SUPERSET_SECRET_KEY: not-a-secret
run: |
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
- name: Upload code coverage
uses: codecov/codecov-action@v5
with:

View File

@@ -24,6 +24,14 @@ assists people when migrating to a new version.
## Next
### Combined datasource list endpoint
Added a new combined datasource list endpoint at `GET /api/v1/datasource/` to serve datasets and semantic views in one response.
- The endpoint is available to users with at least one of `can_read` on `Dataset` or `SemanticView`.
- Semantic views are included only when the `SEMANTIC_LAYERS` feature flag is enabled.
- The endpoint enforces strict `order_column` validation and returns `400` for invalid sort columns.
### ClickHouse minimum driver version bump
The minimum required version of `clickhouse-connect` has been raised to `>=0.13.0`. If you are using the ClickHouse connector, please upgrade your `clickhouse-connect` package. The `_mutate_label` workaround that appended hash suffixes to column aliases has also been removed, as it is no longer needed with modern versions of the driver.

View File

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

View File

@@ -224,3 +224,52 @@ async def analysis_guide(ctx: Context) -> str:
```
See [MCP Integration](./mcp) for implementation details.
### Semantic Layers
Extensions can register custom semantic layer implementations that allow Superset to connect to external data modeling frameworks. Each semantic layer defines how to authenticate, discover semantic views (tables/metrics/dimensions), and execute queries against the external system.
```python
from superset_core.semantic_layers.decorators import semantic_layer
from superset_core.semantic_layers.layer import SemanticLayer
from my_extension.config import MyConfig
from my_extension.view import MySemanticView
@semantic_layer(
id="my_platform",
name="My Data Platform",
description="Connect to My Data Platform's semantic layer",
)
class MySemanticLayer(SemanticLayer[MyConfig, MySemanticView]):
configuration_class = MyConfig
@classmethod
def from_configuration(cls, configuration: dict) -> "MySemanticLayer":
config = MyConfig.model_validate(configuration)
return cls(config)
@classmethod
def get_configuration_schema(cls, configuration=None) -> dict:
return MyConfig.model_json_schema()
@classmethod
def get_runtime_schema(cls, configuration=None, runtime_data=None) -> dict:
return {"type": "object", "properties": {}}
def get_semantic_views(self, runtime_configuration: dict) -> set[MySemanticView]:
# Return available views from the external platform
...
def get_semantic_view(self, name: str, additional_configuration: dict) -> MySemanticView:
# Return a specific view by name
...
```
**Note**: The `@semantic_layer` decorator automatically detects context and applies appropriate ID prefixing:
- **Extension context**: ID prefixed as `extensions.{publisher}.{name}.{id}`
- **Host context**: Original ID used as-is
The decorator registers the class in the semantic layers registry, making it available in the UI for users to create connections. The `configuration_class` should be a Pydantic model that defines the fields needed to connect (credentials, project, database, etc.). Superset uses the model's JSON schema to render the configuration form dynamically.

View File

@@ -75,6 +75,12 @@
"lifecycle": "development",
"description": "Expand nested types in Presto into extra columns/arrays. Experimental, doesn't work with all nested types."
},
{
"name": "SEMANTIC_LAYERS",
"default": false,
"lifecycle": "development",
"description": "Enable semantic layers and show semantic views alongside datasets"
},
{
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
"default": false,

View File

@@ -285,6 +285,7 @@ module = [
"superset.tags.filters",
"superset.commands.security.update",
"superset.commands.security.create",
"superset.semantic_layers.api",
]
warn_unused_ignores = false

View File

@@ -43,6 +43,8 @@ classifiers = [
]
dependencies = [
"flask-appbuilder>=5.0.2,<6",
"isodate>=0.7.0",
"pyarrow>=16.0.0",
"pydantic>=2.8.0",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris

View File

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

View File

@@ -0,0 +1,169 @@
# 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.
"""
Semantic layer DAO interfaces for superset-core.
Provides abstract DAO classes for semantic layers and views that define the
interface contract. Host implementations replace these with concrete classes
backed by SQLAlchemy during initialization.
Usage:
from superset_core.semantic_layers.daos import (
AbstractSemanticLayerDAO,
AbstractSemanticViewDAO,
)
"""
from __future__ import annotations
from abc import abstractmethod
from typing import Any, ClassVar
from superset_core.common.daos import BaseDAO
from superset_core.semantic_layers.models import SemanticLayerModel, SemanticViewModel
class AbstractSemanticLayerDAO(BaseDAO[SemanticLayerModel]):
"""
Abstract DAO interface for SemanticLayer.
Host implementations will replace this class during initialization
with a concrete DAO providing actual database access.
"""
model_cls: ClassVar[type[Any] | None] = None
base_filter = None
id_column_name = "uuid"
uuid_column_name = "uuid"
@classmethod
@abstractmethod
def validate_uniqueness(cls, name: str) -> bool:
"""
Validate that a semantic layer name is unique.
:param name: Semantic layer name to validate
:return: True if the name is unique, False otherwise
"""
...
@classmethod
@abstractmethod
def validate_update_uniqueness(cls, layer_uuid: str, name: str) -> bool:
"""
Validate that a semantic layer name is unique for an update operation,
excluding the layer being updated.
:param layer_uuid: UUID of the semantic layer being updated
:param name: New name to validate
:return: True if the name is unique, False otherwise
"""
...
@classmethod
@abstractmethod
def find_by_name(cls, name: str) -> SemanticLayerModel | None:
"""
Find a semantic layer by name.
:param name: Semantic layer name
:return: SemanticLayerModel instance or None
"""
...
@classmethod
@abstractmethod
def get_semantic_views(cls, layer_uuid: str) -> list[SemanticViewModel]:
"""
Get all semantic views associated with a semantic layer.
:param layer_uuid: UUID of the semantic layer
:return: List of SemanticViewModel instances
"""
...
class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
"""
Abstract DAO interface for SemanticView.
Host implementations will replace this class during initialization
with a concrete DAO providing actual database access.
"""
model_cls: ClassVar[type[Any] | None] = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
@classmethod
@abstractmethod
def validate_uniqueness(
cls,
name: str,
layer_uuid: str,
configuration: dict[str, Any],
) -> bool:
"""
Validate that a semantic view is unique within a semantic layer.
Uniqueness is determined by the combination of name, layer UUID, and
configuration.
:param name: View name
:param layer_uuid: UUID of the parent semantic layer
:param configuration: Configuration dict to compare
:return: True if unique, False otherwise
"""
...
@classmethod
@abstractmethod
def validate_update_uniqueness(
cls,
view_uuid: str,
name: str,
layer_uuid: str,
configuration: dict[str, Any],
) -> bool:
"""
Validate that a semantic view is unique within a semantic layer for an
update operation, excluding the view being updated.
:param view_uuid: UUID of the view being updated
:param name: New name to validate
:param layer_uuid: UUID of the parent semantic layer
:param configuration: Configuration dict to compare
:return: True if unique, False otherwise
"""
...
@classmethod
@abstractmethod
def find_by_name(cls, name: str, layer_uuid: str) -> SemanticViewModel | None:
"""
Find a semantic view by name within a semantic layer.
:param name: View name
:param layer_uuid: UUID of the parent semantic layer
:return: SemanticViewModel instance or None
"""
...
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]

View File

@@ -0,0 +1,102 @@
# 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.
"""
Semantic layer registration decorator for Superset.
This module provides a decorator interface to register semantic layer
implementations with the host application, enabling automatic discovery
by the extensions framework.
Usage:
from superset_core.semantic_layers.decorators import semantic_layer
@semantic_layer(
id="snowflake",
name="Snowflake Cortex",
description="Snowflake semantic layer via Cortex Analyst",
)
class SnowflakeSemanticLayer(SemanticLayer[SnowflakeConfig, SnowflakeView]):
...
# Or with minimal arguments:
@semantic_layer(id="dbt", name="dbt Semantic Layer")
class DbtSemanticLayer(SemanticLayer[DbtConfig, DbtView]):
...
"""
from __future__ import annotations
from typing import Callable, TypeVar
# Type variable for decorated semantic layer classes
T = TypeVar("T")
def semantic_layer(
id: str,
name: str,
description: str | None = None,
) -> Callable[[T], T]:
"""
Decorator to register a semantic layer implementation.
Automatically detects extension context and applies appropriate
namespacing to prevent ID conflicts between host and extension
semantic layers.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
Args:
id: Unique semantic layer type identifier (e.g., "snowflake",
"dbt"). Used as the key in the semantic layers registry and
stored in the ``type`` column of the ``SemanticLayer`` model.
name: Human-readable display name (e.g., "Snowflake Cortex").
Shown in the UI when listing available semantic layer types.
description: Optional description for documentation and UI
tooltips.
Returns:
Decorated semantic layer class registered with the host
application.
Raises:
NotImplementedError: If called before host implementation is
initialized.
Example:
from superset_core.semantic_layers.decorators import semantic_layer
from superset_core.semantic_layers.layer import SemanticLayer
@semantic_layer(
id="snowflake",
name="Snowflake Cortex",
description="Connect to Snowflake Cortex Analyst",
)
class SnowflakeSemanticLayer(
SemanticLayer[SnowflakeConfig, SnowflakeView]
):
...
"""
raise NotImplementedError(
"Semantic layer decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["semantic_layer"]

View File

@@ -0,0 +1,129 @@
# 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 abc import ABC, abstractmethod
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
from superset_core.semantic_layers.view import SemanticView
ConfigT = TypeVar("ConfigT", bound=BaseModel)
SemanticViewT = TypeVar("SemanticViewT", bound="SemanticView")
class SemanticLayer(ABC, Generic[ConfigT, SemanticViewT]):
"""
Abstract base class for semantic layers.
"""
configuration_class: type[BaseModel]
@classmethod
@abstractmethod
def from_configuration(
cls,
configuration: dict[str, Any],
) -> SemanticLayer[ConfigT, SemanticViewT]:
"""
Create a semantic layer from its configuration.
"""
raise NotImplementedError(
"Semantic layers must implement the from_configuration method"
)
@classmethod
@abstractmethod
def get_configuration_schema(
cls,
configuration: ConfigT | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the configuration needed to add the semantic layer.
A partial configuration `configuration` can be sent to improve the schema,
allowing for progressive validation and better UX. For example, a semantic
layer might require:
- auth information
- a database
If the user provides the auth information, a client can send the partial
configuration to this method, and the resulting JSON schema would include
the list of databases the user has access to, allowing a dropdown to be
populated.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are populated based on the provided connection info.
"""
raise NotImplementedError(
"Semantic layers must implement the get_configuration_schema method"
)
@classmethod
@abstractmethod
def get_runtime_schema(
cls,
configuration: ConfigT,
runtime_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Get the JSON schema for the runtime parameters needed to load semantic views.
This returns the schema needed to connect to a semantic view given the
configuration for the semantic layer. For example, a semantic layer might
be configured by:
- auth information
- an optional database
If the user does not provide a database when creating the semantic layer, the
runtime schema would require the database name to be provided before loading any
semantic views. This allows users to create semantic layers that connect to a
specific database (or project, account, etc.), or that allow users to select it
at query time.
The Snowflake semantic layer has an example implementation of this method, where
database and schema names are required if they were not provided in the initial
configuration.
"""
raise NotImplementedError(
"Semantic layers must implement the get_runtime_schema method"
)
@abstractmethod
def get_semantic_views(
self,
runtime_configuration: dict[str, Any],
) -> set[SemanticViewT]:
"""
Get the semantic views available in the semantic layer.
The runtime configuration can provide information like a given project or
schema, used to restrict the semantic views returned.
"""
@abstractmethod
def get_semantic_view(
self,
name: str,
additional_configuration: dict[str, Any],
) -> SemanticViewT:
"""
Get a specific semantic view by its name and additional configuration.
"""

View File

@@ -0,0 +1,85 @@
# 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.
"""
Semantic layer model interfaces for superset-core.
Provides abstract model classes for semantic layers and views that will be
replaced by the host implementation's concrete SQLAlchemy models during
initialization.
Usage:
from superset_core.semantic_layers.models import (
SemanticLayerModel,
SemanticViewModel,
)
"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from superset_core.common.models import CoreModel
class SemanticLayerModel(CoreModel):
"""
Abstract interface for the SemanticLayer database model.
Host implementations will replace this class during initialization
with a concrete SQLAlchemy model providing actual persistence.
"""
__abstract__ = True
# Type hints for expected column attributes
uuid: UUID
name: str
description: str | None
type: str
configuration: str
configuration_version: int
cache_timeout: int | None
created_on: datetime | None
changed_on: datetime | None
class SemanticViewModel(CoreModel):
"""
Abstract interface for the SemanticView database model.
Host implementations will replace this class during initialization
with a concrete SQLAlchemy model providing actual persistence.
"""
__abstract__ = True
# Type hints for expected column attributes
id: int
uuid: UUID
name: str
description: str | None
configuration: str
configuration_version: int
cache_timeout: int | None
semantic_layer_uuid: UUID
created_on: datetime | None
changed_on: datetime | None
__all__ = ["SemanticLayerModel", "SemanticViewModel"]

View File

@@ -0,0 +1,209 @@
# 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 enum
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
import isodate
import pyarrow as pa
@dataclass(frozen=True)
class Grain:
"""
Represents a time grain (e.g., day, month, year).
Attributes:
name: Human-readable name of the grain (e.g., "Second")
representation: ISO 8601 duration (e.g., "PT1S", "P1D", "P1M")
"""
name: str
representation: str
def __post_init__(self) -> None:
isodate.parse_duration(self.representation)
def __eq__(self, other: object) -> bool:
if isinstance(other, Grain):
return self.representation == other.representation
return NotImplemented
def __hash__(self) -> int:
return hash(self.representation)
class Grains:
"""Pre-defined common grains and factory for custom ones."""
SECOND = Grain("Second", "PT1S")
MINUTE = Grain("Minute", "PT1M")
HOUR = Grain("Hour", "PT1H")
DAY = Grain("Day", "P1D")
WEEK = Grain("Week", "P1W")
MONTH = Grain("Month", "P1M")
QUARTER = Grain("Quarter", "P3M")
YEAR = Grain("Year", "P1Y")
_REGISTRY: dict[str, Grain] = {
"PT1S": SECOND,
"PT1M": MINUTE,
"PT1H": HOUR,
"P1D": DAY,
"P1W": WEEK,
"P1M": MONTH,
"P3M": QUARTER,
"P1Y": YEAR,
}
@classmethod
def get(cls, representation: str, name: str | None = None) -> Grain:
"""Return a pre-defined grain or create a custom one."""
if grain := cls._REGISTRY.get(representation):
return grain
return Grain(name or representation, representation)
@dataclass(frozen=True)
class Dimension:
id: str
name: str
type: pa.DataType
definition: str | None = None
description: str | None = None
grain: Grain | None = None
@dataclass(frozen=True)
class Metric:
id: str
name: str
type: pa.DataType
definition: str
description: str | None = None
@dataclass(frozen=True)
class AdhocExpression:
id: str
definition: str
class Operator(str, enum.Enum):
EQUALS = "="
NOT_EQUALS = "!="
GREATER_THAN = ">"
LESS_THAN = "<"
GREATER_THAN_OR_EQUAL = ">="
LESS_THAN_OR_EQUAL = "<="
IN = "IN"
NOT_IN = "NOT IN"
LIKE = "LIKE"
NOT_LIKE = "NOT LIKE"
IS_NULL = "IS NULL"
IS_NOT_NULL = "IS NOT NULL"
ADHOC = "ADHOC"
FilterValues = str | int | float | bool | datetime | date | time | timedelta | None
class PredicateType(enum.Enum):
WHERE = "WHERE"
HAVING = "HAVING"
@dataclass(frozen=True, order=True)
class Filter:
type: PredicateType
column: Dimension | Metric | None
operator: Operator
value: FilterValues | frozenset[FilterValues]
class OrderDirection(enum.Enum):
ASC = "ASC"
DESC = "DESC"
OrderTuple = tuple[Metric | Dimension | AdhocExpression, OrderDirection]
@dataclass(frozen=True)
class GroupLimit:
"""
Limit query to top/bottom N combinations of specified dimensions.
The `filters` parameter allows specifying separate filter constraints for the
group limit subquery. This is useful when you want to determine the top N groups
using different criteria (e.g., a different time range) than the main query.
For example, you might want to find the top 10 products by sales over the last
30 days, but then show daily sales for those products over the last 7 days.
"""
dimensions: list[Dimension]
top: int
metric: Metric | None
direction: OrderDirection = OrderDirection.DESC
group_others: bool = False
filters: set[Filter] | None = None
@dataclass(frozen=True)
class SemanticRequest:
"""
Represents a request made to obtain semantic results.
This could be a SQL query, an HTTP request, etc.
"""
type: str
definition: str
@dataclass(frozen=True)
class SemanticResult:
"""
Represents the results of a semantic query.
This includes any requests (SQL queries, HTTP requests) that were performed in order
to obtain the results, in order to help troubleshooting.
"""
requests: list[SemanticRequest]
results: pa.Table
@dataclass(frozen=True)
class SemanticQuery:
"""
Represents a semantic query.
"""
metrics: list[Metric]
dimensions: list[Dimension]
filters: set[Filter] | None = None
order: list[OrderTuple] | None = None
limit: int | None = None
offset: int | None = None
group_limit: GroupLimit | None = None

View File

@@ -0,0 +1,113 @@
# 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 enum
from abc import ABC, abstractmethod
from superset_core.semantic_layers.types import (
Dimension,
Filter,
Metric,
SemanticQuery,
SemanticResult,
)
# TODO (betodealmeida): move to the extension JSON
class SemanticViewFeature(enum.Enum):
"""
Custom features supported by semantic layers.
"""
ADHOC_EXPRESSIONS_IN_ORDERBY = "ADHOC_EXPRESSIONS_IN_ORDERBY"
GROUP_LIMIT = "GROUP_LIMIT"
GROUP_OTHERS = "GROUP_OTHERS"
class SemanticView(ABC):
"""
Abstract base class for semantic views.
"""
features: frozenset[SemanticViewFeature]
# 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:
"""
Returns a unique identifier for the semantic view.
"""
@abstractmethod
def get_dimensions(self) -> set[Dimension]:
"""
Get the dimensions defined in the semantic view.
"""
@abstractmethod
def get_metrics(self) -> set[Metric]:
"""
Get the metrics defined in the semantic view.
"""
@abstractmethod
def get_values(
self,
dimension: Dimension,
filters: set[Filter] | None = None,
) -> SemanticResult:
"""
Return distinct values for a dimension.
"""
@abstractmethod
def get_table(self, query: SemanticQuery) -> SemanticResult:
"""
Execute a semantic query and return the results.
"""
@abstractmethod
def get_row_count(self, query: SemanticQuery) -> SemanticResult:
"""
Execute a query and return the number of rows the result would have.
"""
@abstractmethod
def get_compatible_metrics(
self,
selected_metrics: set[Metric],
selected_dimensions: set[Dimension],
) -> set[Metric]:
"""
Return metrics compatible with the selected dimensions.
"""
@abstractmethod
def get_compatible_dimensions(
self,
selected_metrics: set[Metric],
selected_dimensions: set[Dimension],
) -> set[Dimension]:
"""
Return dimensions compatible with the selected metrics.
"""

View File

@@ -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,11 +4068,10 @@
}
},
"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"
}
@@ -4073,6 +4088,26 @@
"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",
"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/@hapi/address": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.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",

View File

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

View File

@@ -23,7 +23,7 @@ import { Label } from '..';
// Define the prop types for DatasetTypeLabel
interface DatasetTypeLabelProps {
datasetType: 'physical' | 'virtual'; // Accepts only 'physical' or 'virtual'
datasetType: 'physical' | 'virtual' | 'semantic_view';
}
const SIZE = 's'; // Define the size as a constant
@@ -32,6 +32,24 @@ export const DatasetTypeLabel: React.FC<DatasetTypeLabelProps> = ({
datasetType,
}) => {
const theme = useTheme();
if (datasetType === 'semantic_view') {
return (
<Label
icon={
<Icons.ApartmentOutlined
iconSize={SIZE}
iconColor={theme.colorInfo}
/>
}
type="info"
style={{ color: theme.colorInfo }}
>
{t('Semantic')}
</Label>
);
}
const label: string =
datasetType === 'physical' ? t('Physical') : t('Virtual');
const icon =

View File

@@ -19,6 +19,15 @@
import { DatasourceType } from './types/Datasource';
const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
table: DatasourceType.Table,
query: DatasourceType.Query,
dataset: DatasourceType.Dataset,
sl_table: DatasourceType.SlTable,
saved_query: DatasourceType.SavedQuery,
semantic_view: DatasourceType.SemanticView,
};
export default class DatasourceKey {
readonly id: number;
@@ -27,8 +36,7 @@ export default class DatasourceKey {
constructor(key: string) {
const [idStr, typeStr] = key.split('__');
this.id = parseInt(idStr, 10);
this.type = DatasourceType.Table; // default to SqlaTable model
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
}
public toString() {

View File

@@ -26,6 +26,7 @@ export enum DatasourceType {
Dataset = 'dataset',
SlTable = 'sl_table',
SavedQuery = 'saved_query',
SemanticView = 'semantic_view',
}
export interface Currency {
@@ -40,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;

View File

@@ -60,6 +60,7 @@ export enum FeatureFlag {
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
Matrixify = 'MATRIXIFY',
ScheduledQueries = 'SCHEDULED_QUERIES',
SemanticLayers = 'SEMANTIC_LAYERS',
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',
SqlValidatorsByEngine = 'SQL_VALIDATORS_BY_ENGINE',
SshTunneling = 'SSH_TUNNELING',

View File

@@ -28,10 +28,11 @@ test('DEFAULT_METRICS', () => {
});
test('DatasourceType', () => {
expect(Object.keys(DatasourceType).length).toBe(5);
expect(Object.keys(DatasourceType).length).toBe(6);
expect(DatasourceType.Table).toBe('table');
expect(DatasourceType.Query).toBe('query');
expect(DatasourceType.Dataset).toBe('dataset');
expect(DatasourceType.SlTable).toBe('sl_table');
expect(DatasourceType.SavedQuery).toBe('saved_query');
expect(DatasourceType.SemanticView).toBe('semantic_view');
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';
@@ -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,20 @@ 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.
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
// ref is populated before the first paint and after every update.
useLayoutEffect(() => {
if (filtersRef) {
(filtersRef as React.MutableRefObject<typeof filterControlsRef.current>).current =
filterControlsRef.current;
}
});
const handleClearFilterControls = useCallback(() => {
if (query.filters) {

View File

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

View File

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

View File

@@ -51,6 +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';
interface ColumnApiResponse {
column_name?: string;
@@ -262,7 +266,7 @@ const GroupByFilterCardContent: FC<{
</Row>
<Row>
<RowLabel>{t('Dataset')}</RowLabel>
<RowLabel>{getDatasetLabel()}</RowLabel>
<RowValue>
{typeof datasetLabel === 'string' ? datasetLabel : 'Dataset'}
</RowValue>
@@ -475,7 +479,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);

View File

@@ -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())}
/>
);
};

View File

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

View File

@@ -151,11 +151,8 @@ export const getSlicePayload = async (
const [id, typeString] = formData.datasource.split('__');
datasourceId = parseInt(id, 10);
const formattedTypeString =
typeString.charAt(0).toUpperCase() + typeString.slice(1);
if (formattedTypeString in DatasourceType) {
datasourceType =
DatasourceType[formattedTypeString as keyof typeof DatasourceType];
if (Object.values(DatasourceType).includes(typeString as DatasourceType)) {
datasourceType = typeString as DatasourceType;
}
}

View File

@@ -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} />;
}

View File

@@ -40,11 +40,13 @@ 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';
import { URL_PARAMS } from 'src/constants';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
import {
userHasPermission,
isUserAdmin,
@@ -68,6 +70,7 @@ interface ExtendedDatasource extends Datasource {
}>;
extra?: string;
health_check_message?: string;
cache_timeout?: number | null;
database?: {
id: number;
database_name: string;
@@ -375,7 +378,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 +390,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 +407,7 @@ class DatasourceControl extends PureComponent<
defaultDatasourceMenuItems.push({
key: CHANGE_DATASET,
label: t('Swap dataset'),
label: t('Swap %s', datasetLabelLower()),
});
if (!isMissingDatasource && canAccessSqlLab) {
@@ -481,7 +486,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 +500,7 @@ class DatasourceControl extends PureComponent<
const titleText =
isMissingDatasource && !datasource.name
? t('Missing dataset')
? t('Missing %s', datasetLabelLower())
: getDatasourceTitle(datasource);
const tooltip = titleText;
@@ -561,14 +566,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 +584,7 @@ class DatasourceControl extends PureComponent<
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap dataset')}
{t('Swap %s', datasetLabelLower())}
</Button>
</p>
</>
@@ -587,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}

View File

@@ -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
}
/>

View File

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

View File

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

View File

@@ -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
}
/>

View File

@@ -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) => ({

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,127 @@
/**
* 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 { SupersetClient } from '@superset-ui/core';
import { render, waitFor } from 'spec/helpers/testing-library';
import SemanticLayerModal from './SemanticLayerModal';
let mockJsonFormsChangeTriggered = false;
jest.mock('@jsonforms/react', () => ({
...jest.requireActual('@jsonforms/react'),
JsonForms: ({ onChange }: { onChange: (value: unknown) => void }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
if (!mockJsonFormsChangeTriggered) {
mockJsonFormsChangeTriggered = true;
onChange({
data: { warehouse: 'wh1' },
errors: [],
});
}
return null;
},
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
...jest.requireActual('@superset-ui/core').SupersetClient,
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
},
getClientErrorObject: jest.fn(() => Promise.resolve({ error: '' })),
}));
const mockedGet = SupersetClient.get as jest.Mock;
const mockedPost = SupersetClient.post as jest.Mock;
const props = {
show: true,
onHide: jest.fn(),
addDangerToast: jest.fn(),
addSuccessToast: jest.fn(),
semanticLayerUuid: '11111111-1111-1111-1111-111111111111',
};
beforeEach(() => {
mockJsonFormsChangeTriggered = false;
jest.useFakeTimers();
mockedGet.mockReset();
mockedPost.mockReset();
mockedGet
.mockResolvedValueOnce({
json: {
result: [{ id: 'snowflake', name: 'Snowflake', description: '' }],
},
})
.mockResolvedValueOnce({
json: {
result: {
name: 'Layer 1',
type: 'snowflake',
configuration: { warehouse: 'wh0' },
},
},
});
mockedPost.mockResolvedValue({
json: {
result: {
type: 'object',
properties: {
warehouse: {
type: 'string',
'x-dynamic': true,
'x-dependsOn': ['warehouse'],
},
},
},
},
});
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
test('posts configuration schema refresh after debounce', async () => {
render(<SemanticLayerModal {...props} />);
await waitFor(() => {
expect(mockedPost).toHaveBeenCalledWith({
endpoint: '/api/v1/semantic_layer/schema/configuration',
jsonPayload: { type: 'snowflake' },
});
});
jest.advanceTimersByTime(501);
await waitFor(() => {
expect(mockedPost).toHaveBeenCalledWith({
endpoint: '/api/v1/semantic_layer/schema/configuration',
jsonPayload: {
type: 'snowflake',
configuration: { warehouse: 'wh1' },
},
});
});
});

View File

@@ -0,0 +1,391 @@
/**
* 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 { SupersetClient, getClientErrorObject } from '@superset-ui/core';
import { Input, Select, Button } 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 './jsonFormsHelpers';
type Step = 'type' | 'config';
type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
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 (json.warning) {
addDangerToast(String(json.warning));
}
if (isInitialFetch) setStep('config');
} catch (error) {
const clientError = await getClientErrorObject(error);
if (isInitialFetch) {
addDangerToast(
clientError.error ||
t('An error occurred while fetching the configuration schema'),
);
} else {
addDangerToast(
clientError.error ||
t('An error occurred while refreshing 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 (error) {
const clientError = await getClientErrorObject(error);
addDangerToast(
clientError.error ||
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 (error) {
const clientError = await getClientErrorObject(error);
addDangerToast(
clientError.error ||
(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);
maybeRefreshSchema(data);
},
[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' ? (
<>
<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>
</>
) : (
<>
{!isEditMode && (
<Button
buttonStyle="link"
icon={<Icons.CaretLeftOutlined iconSize="s" />}
onClick={handleBack}
>
{t('Back')}
</Button>
)}
<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}
/>
)}
</>
)}
</StandardModal>
);
}

View File

@@ -0,0 +1,306 @@
/**
* 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/translation';
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 &&
!(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.
*/
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
* 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,
readOnlyEntry,
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 } as 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 {
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;
}
/**
* 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);
}

View File

@@ -0,0 +1,512 @@
/**
* 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 { 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,
} from 'src/components/Modal';
import {
renderers,
sanitizeSchema,
buildUiSchema,
getDynamicDependencies,
areDependenciesSatisfied,
serializeDependencyValues,
SCHEMA_REFRESH_DEBOUNCE_MS,
} from 'src/features/semanticLayers/jsonFormsHelpers';
interface SemanticLayerOption {
uuid: string;
name: string;
}
interface AvailableView {
name: string;
already_added: boolean;
}
const ModalContent = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 4}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;
`;
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;
}
/* Reset antd default margins so gap controls all spacing */
&& .ant-form-item {
margin-bottom: 0;
}
/* 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 {
show: boolean;
onHide: () => void;
onSuccess: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
export default function AddSemanticViewModal({
show,
onHide,
onSuccess,
addDangerToast,
addSuccessToast,
}: AddSemanticViewModalProps) {
// --- Layer ---
const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
null,
);
const [loadingLayers, setLoadingLayers] = useState(false);
// --- Runtime config ---
const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
const [runtimeUiSchema, setRuntimeUiSchema] = useState<
UISchemaElement | undefined
>();
const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
const [loadingRuntime, setLoadingRuntime] = useState(false);
const [refreshingSchema, setRefreshingSchema] = useState(false);
const errorsRef = useRef<ErrorObject[]>([]);
const dynamicDepsRef = useRef<Record<string, string[]>>({});
const lastDepSnapshotRef = useRef('');
const schemaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// --- Views ---
const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
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 () => {
setLoadingLayers(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 {
setLoadingLayers(false);
}
};
const fetchViews = useCallback(
async (uuid: string, rData: Record<string, unknown>, gen: number) => {
setLoadingViews(true);
setAvailableViews([]);
setSelectedViewNames([]);
try {
const { json } = await SupersetClient.post({
endpoint: `/api/v1/semantic_layer/${uuid}/views`,
jsonPayload: { runtime_data: rData },
});
if (gen !== fetchGenRef.current) return;
setAvailableViews(json.result ?? []);
} catch {
if (gen !== fetchGenRef.current) return;
addDangerToast(t('An error occurred while fetching available views'));
} finally {
if (gen === fetchGenRef.current) setLoadingViews(false);
}
},
[addDangerToast],
);
const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
const schema = sanitizeSchema(rawSchema);
setRuntimeSchema(schema);
setRuntimeUiSchema(buildUiSchema(schema));
dynamicDepsRef.current = getDynamicDependencies(rawSchema);
}, []);
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: {},
});
if (gen !== fetchGenRef.current) return;
const schema = json.result;
if (
!schema?.properties ||
Object.keys(schema.properties).length === 0
) {
// No runtime config needed — fetch views right away
fetchViews(uuid, {}, gen);
} else {
applyRuntimeSchema(schema);
}
} catch {
if (gen !== fetchGenRef.current) return;
addDangerToast(
t('An error occurred while fetching the runtime schema'),
);
} finally {
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);
}
},
[selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews],
);
// 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 {
fetchGenRef.current += 1;
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
setLayers([]);
setSelectedLayerUuid(null);
setLoadingLayers(false);
setRuntimeSchema(null);
setRuntimeUiSchema(undefined);
setRuntimeData({});
setLoadingRuntime(false);
setRefreshingSchema(false);
errorsRef.current = [];
dynamicDepsRef.current = {};
lastDepSnapshotRef.current = '';
setAvailableViews([]);
setSelectedViewNames([]);
setLoadingViews(false);
setSaving(false);
lastViewsKeyRef.current = '';
}
}, [show]); // eslint-disable-line react-hooks/exhaustive-deps
// =========================================================================
// Save
// =========================================================================
const newViewCount = availableViews.filter(
v => selectedViewNames.includes(v.name) && !v.already_added,
).length;
const handleSave = async () => {
if (!selectedLayerUuid || newViewCount === 0) return;
setSaving(true);
try {
const viewsToCreate = availableViews
.filter(v => selectedViewNames.includes(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);
}
};
// =========================================================================
// Render
// =========================================================================
const hasRuntimeFields =
runtimeSchema?.properties &&
Object.keys(runtimeSchema.properties).length > 0;
const viewsDisabled =
loadingViews || (!loadingViews && availableViews.length === 0);
return (
<StandardModal
show={show}
onHide={onHide}
onSave={handleSave}
title={t('Add Semantic View')}
icon={<Icons.PlusOutlined />}
width={MODAL_STANDARD_WIDTH}
saveDisabled={newViewCount === 0 || saving}
saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
saveLoading={saving}
>
<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 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}
/>
</ModalFormField>
)}
</ModalContent>
</StandardModal>
);
}

View File

@@ -0,0 +1,95 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
import SemanticViewEditModal from './SemanticViewEditModal';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
...jest.requireActual('@superset-ui/core').SupersetClient,
put: jest.fn(),
},
getClientErrorObject: jest.fn(() => Promise.resolve({ error: '' })),
}));
const mockedPut = SupersetClient.put as jest.Mock;
const mockedGetClientErrorObject = getClientErrorObject as jest.Mock;
const createProps = () => ({
show: true,
onHide: jest.fn(),
onSave: jest.fn(),
addDangerToast: jest.fn(),
addSuccessToast: jest.fn(),
semanticView: {
id: 7,
table_name: 'orders_semantic_view',
description: 'old description',
cache_timeout: 60,
},
});
beforeEach(() => {
mockedPut.mockReset();
mockedGetClientErrorObject.mockReset();
mockedGetClientErrorObject.mockResolvedValue({ error: '' });
});
test('saves semantic view and refreshes list', async () => {
mockedPut.mockResolvedValue({});
const props = createProps();
render(<SemanticViewEditModal {...props} />);
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedPut).toHaveBeenCalledWith({
endpoint: '/api/v1/semantic_view/7',
jsonPayload: {
description: 'old description',
cache_timeout: 60,
},
});
});
expect(props.addSuccessToast).toHaveBeenCalledWith('Semantic view updated');
expect(props.onSave).toHaveBeenCalled();
expect(props.onHide).toHaveBeenCalled();
});
test('shows backend error toast when save fails', async () => {
mockedPut.mockRejectedValue(new Error('save failed'));
mockedGetClientErrorObject.mockResolvedValue({
error: 'Semantic view failed to save',
});
const props = createProps();
render(<SemanticViewEditModal {...props} />);
await userEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(props.addDangerToast).toHaveBeenCalledWith(
'Semantic view failed to save',
);
});
});

View File

@@ -0,0 +1,119 @@
/**
* 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 } from 'react';
import { t } from '@apache-superset/core/translation';
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
import { Input, InputNumber } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import {
StandardModal,
ModalFormField,
MODAL_STANDARD_WIDTH,
} from 'src/components/Modal';
type InputNumberValue = number | null;
interface SemanticViewEditModalProps {
show: boolean;
onHide: () => void;
onSave: () => void;
addDangerToast?: (msg: string) => void;
addSuccessToast?: (msg: string) => void;
semanticView: {
id: number;
table_name: string;
description?: string | null;
cache_timeout?: number | null;
} | null;
}
export default function SemanticViewEditModal({
show,
onHide,
onSave,
addDangerToast = () => {},
addSuccessToast = () => {},
semanticView,
}: SemanticViewEditModalProps) {
const [description, setDescription] = useState<string>('');
const [cacheTimeout, setCacheTimeout] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (semanticView) {
setDescription(semanticView.description || '');
setCacheTimeout(semanticView.cache_timeout ?? null);
}
}, [semanticView]);
const handleSave = async () => {
if (!semanticView) return;
setSaving(true);
try {
await SupersetClient.put({
endpoint: `/api/v1/semantic_view/${semanticView.id}`,
jsonPayload: {
description: description || null,
cache_timeout: cacheTimeout,
},
});
addSuccessToast(t('Semantic view updated'));
onSave();
onHide();
} catch (error) {
const clientError = await getClientErrorObject(error);
addDangerToast(
clientError.error ||
t('An error occurred while saving the semantic view'),
);
} finally {
setSaving(false);
}
};
return (
<StandardModal
show={show}
onHide={onHide}
onSave={handleSave}
title={t('Edit %s', semanticView?.table_name || '')}
icon={<Icons.EditOutlined />}
isEditMode
width={MODAL_STANDARD_WIDTH}
saveLoading={saving}
>
<ModalFormField label={t('Description')}>
<Input.TextArea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
/>
</ModalFormField>
<ModalFormField label={t('Cache timeout')}>
<InputNumber
value={cacheTimeout}
onChange={value => setCacheTimeout(value as InputNumberValue)}
min={0}
placeholder={t('Duration in seconds')}
style={{ width: '100%' }}
/>
</ModalFormField>
</StandardModal>
);
}

View File

@@ -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;
@@ -332,18 +333,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 +375,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

View File

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

View File

@@ -17,9 +17,15 @@
* 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 type { CellProps } from 'react-table';
import rison from 'rison';
import { useSelector } from 'react-redux';
import { useQueryParams, BooleanParam } from 'use-query-params';
@@ -33,7 +39,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 +51,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,10 +64,17 @@ 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';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
import type Owner from 'src/types/Owner';
import {
databaseLabel,
databaseLabelLower,
databasesLabel,
} from 'src/utils/semanticLayerLabels';
const extensionsRegistry = getExtensionsRegistry();
const DatabaseDeleteRelatedExtension = extensionsRegistry.get(
@@ -70,6 +86,13 @@ const dbConfigExtraExtension = extensionsRegistry.get(
const PAGE_SIZE = 25;
type ConnectionItem = DatabaseObject & {
source_type?: 'database' | 'semantic_layer';
sl_type?: string;
changed_by?: Owner;
changed_on_delta_humanized?: string;
};
interface DatabaseDeleteObject extends DatabaseObject {
charts: any;
dashboards: any;
@@ -108,20 +131,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'),
databaseLabelLower(),
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 +257,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);
@@ -316,22 +432,67 @@ function DatabaseList({
const menuData: SubMenuProps = {
activeChild: 'Databases',
dropDownLinks: filteredDropDown,
name: t('Databases'),
name: databasesLabel(),
};
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: databaseLabel(),
buttonStyle: 'primary',
onClick: openDatabaseModal,
},
];
}
}
const handleDatabaseExport = useCallback(
@@ -345,9 +506,11 @@ function DatabaseList({
await handleResourceExport('database', [database.id], () => {
setPreparingExport(false);
});
} catch (error) {
} catch {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the database'));
addDangerToast(
t('There was an issue exporting the %s', databaseLabelLower()),
);
}
},
[addDangerToast, setPreparingExport],
@@ -401,6 +564,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 +593,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 +607,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 } }: CellProps<ConnectionItem>) =>
original.source_type === 'semantic_layer' ? (
<span></span>
) : (
<BooleanDisplay value={Boolean(original.allow_run_async)} />
),
size: 'sm',
id: 'allow_run_async',
},
@@ -448,33 +627,36 @@ function DatabaseList({
<span>{t('DML')}</span>
</Tooltip>
),
Cell: ({
row: {
original: { allow_dml: allowDML },
},
}: any) => <BooleanDisplay value={allowDML} />,
Cell: ({ row: { original } }: CellProps<ConnectionItem>) =>
original.source_type === 'semantic_layer' ? (
<span></span>
) : (
<BooleanDisplay value={Boolean(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 } }: CellProps<ConnectionItem>) =>
original.source_type === 'semantic_layer' ? (
<span></span>
) : (
<BooleanDisplay value={Boolean(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 } }: CellProps<ConnectionItem>) =>
original.source_type === 'semantic_layer' ? (
<span></span>
) : (
<BooleanDisplay value={Boolean(original.expose_in_sqllab)} />
),
size: 'md',
id: 'expose_in_sqllab',
},
@@ -486,7 +668,9 @@ function DatabaseList({
changed_on_delta_humanized: changedOn,
},
},
}: any) => <ModifiedInfo date={changedOn} user={changedBy} />,
}: CellProps<ConnectionItem>) => (
<ModifiedInfo date={changedOn || ''} user={changedBy} />
),
Header: t('Last modified'),
accessor: 'changed_on_delta_humanized',
size: 'xl',
@@ -494,6 +678,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);
@@ -564,7 +790,7 @@ function DatabaseList({
>
<Tooltip
id="delete-action-tooltip"
title={t('Delete database')}
title={t('Delete %s', databaseLabelLower())}
placement="bottom"
>
<Icons.DeleteOutlined iconSize="l" />
@@ -579,6 +805,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 +828,8 @@ function DatabaseList({
],
);
const filters: ListViewFilters = useMemo(
() => [
const filters: ListViewFilters = useMemo(() => {
const baseFilters: ListViewFilters = [
{
Header: t('Name'),
key: 'search',
@@ -605,62 +837,84 @@ function DatabaseList({
input: 'search',
operator: FilterOperator.Contains,
},
{
Header: t('Expose in SQL Lab'),
key: 'expose_in_sql_lab',
id: 'expose_in_sqllab',
];
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: (
<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,
),
});
}
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>
),
user,
),
paginate: true,
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
},
],
[user],
);
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 %s values: %s',
databaseLabelLower(),
errMsg,
),
),
user,
),
paginate: true,
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
},
);
}
return baseFilters;
}, [showSemanticLayers]);
return (
<>
@@ -703,12 +957,54 @@ 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={
<>
<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.',
@@ -816,7 +1112,7 @@ function DatabaseList({
title={
<ModalTitleWithIcon
icon={<Icons.DeleteOutlined />}
title={t('Delete Database?')}
title={t('Delete %s?', databaseLabel())}
/>
}
/>

View File

@@ -31,6 +31,7 @@ import {
mockRelatedCharts,
mockRelatedDashboards,
mockHandleResourceExport,
mockDatasetListEndpoints,
API_ENDPOINTS,
} from './DatasetList.testHelpers';
@@ -98,7 +99,7 @@ test('typing in search triggers debounced API call with search filter', async ()
// Record initial API calls
const initialCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Type search query and submit with Enter to trigger the debounced fetch
@@ -107,14 +108,16 @@ test('typing in search triggers debounced API call with search filter', async ()
// Wait for debounced API call
await waitFor(
() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(initialCallCount);
},
{ timeout: 5000 },
);
// Verify the latest API call includes search filter in URL
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
const latestCall = calls[calls.length - 1];
const { url } = latestCall;
@@ -136,8 +139,7 @@ test('typing in search triggers debounced API call with search filter', async ()
test('500 error triggers danger toast with error message', async () => {
const addDangerToast = jest.fn();
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
status: 500,
body: { message: 'Internal Server Error' },
});
@@ -173,8 +175,7 @@ test('500 error triggers danger toast with error message', async () => {
test('network timeout triggers danger toast', async () => {
const addDangerToast = jest.fn();
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
throws: new Error('Network timeout'),
});
@@ -213,8 +214,7 @@ test('clicking delete opens modal with related objects count', async () => {
// Set up delete mocks
setupDeleteMocks(datasetToDelete.id);
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetToDelete],
count: 1,
});
@@ -254,8 +254,7 @@ test('clicking delete opens modal with related objects count', async () => {
test('clicking export calls handleResourceExport with dataset ID', async () => {
const datasetToExport = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetToExport],
count: 1,
});
@@ -288,8 +287,7 @@ test('clicking duplicate opens modal and submits duplicate request', async () =>
kind: 'virtual',
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetToDuplicate],
count: 1,
});
@@ -312,7 +310,7 @@ test('clicking duplicate opens modal and submits duplicate request', async () =>
// Track initial dataset list API calls BEFORE duplicate action
const initialDatasetCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
const row = screen.getByText(datasetToDuplicate.table_name).closest('tr');
@@ -355,7 +353,9 @@ test('clicking duplicate opens modal and submits duplicate request', async () =>
// Verify refreshData() is called (observable via new dataset list API call)
await waitFor(
() => {
const datasetCalls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const datasetCalls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(datasetCalls.length).toBeGreaterThan(initialDatasetCallCount);
},
{ timeout: 3000 },
@@ -376,8 +376,7 @@ test('certified dataset shows badge and tooltip with certification details', asy
}),
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [certifiedDataset],
count: 1,
});
@@ -417,8 +416,7 @@ test('dataset with warning shows icon and tooltip with markdown content', async
}),
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetWithWarning],
count: 1,
});
@@ -452,8 +450,7 @@ test('dataset with warning shows icon and tooltip with markdown content', async
test('dataset name links to Explore with correct URL and accessible label', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);

View File

@@ -27,6 +27,7 @@ import {
mockAdminUser,
mockDatasets,
setupBulkDeleteMocks,
mockDatasetListEndpoints,
API_ENDPOINTS,
} from './DatasetList.testHelpers';
@@ -72,8 +73,7 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
// the ListView provider correctly merges them for the API call.
// Component tests verify individual pieces persist; this verifies they COMBINE correctly.
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: mockDatasets,
count: mockDatasets.length,
});
@@ -91,31 +91,33 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
});
const callsBeforeSort = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
await userEvent.click(nameHeader);
// Wait for sort-triggered refetch to complete before applying filter
await waitFor(() => {
expect(
fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).length,
fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED).length,
).toBeGreaterThan(callsBeforeSort);
});
// 2. Apply a filter using selectOption helper
const beforeFilterCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
await selectOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(beforeFilterCallCount);
});
// 3. Verify the final API call contains ALL three state pieces merged correctly
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
const latestCall = calls[calls.length - 1];
const { url } = latestCall;
@@ -151,8 +153,7 @@ test('bulk action orchestration: selection → action → cleanup cycle works co
setupBulkDeleteMocks();
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: mockDatasets,
count: mockDatasets.length,
});
@@ -218,7 +219,7 @@ test('bulk action orchestration: selection → action → cleanup cycle works co
// Capture datasets call count before confirming
const datasetsCallCountBeforeDelete = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
const confirmButton = within(modal)
@@ -242,7 +243,7 @@ test('bulk action orchestration: selection → action → cleanup cycle works co
// Wait for datasets refetch after delete
await waitFor(() => {
const datasetsCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDelete);
});

View File

@@ -33,6 +33,7 @@ import {
mockHandleResourceExport,
assertOnlyExpectedCalls,
API_ENDPOINTS,
mockDatasetListEndpoints,
getDeleteRouteName,
} from './DatasetList.testHelpers';
@@ -113,8 +114,7 @@ const setupErrorTestScenario = ({
});
// Configure fetchMock to return single dataset
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
// Render component with toast mocks
renderDatasetList(mockAdminUser, {
@@ -157,7 +157,7 @@ test('required API endpoints are called and no unmocked calls on initial render'
// assertOnlyExpectedCalls checks: 1) no unmatched calls, 2) each expected endpoint was called
assertOnlyExpectedCalls([
API_ENDPOINTS.DATASETS_INFO, // Permission check
API_ENDPOINTS.DATASETS, // Main dataset list data
API_ENDPOINTS.DATASOURCE_COMBINED, // Main dataset list data
]);
});
@@ -197,8 +197,7 @@ test('renders all required column headers', async () => {
test('displays dataset name in Name column', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -211,8 +210,7 @@ test('displays dataset type as Physical or Virtual', async () => {
const physicalDataset = mockDatasets[0]; // kind: 'physical'
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [physicalDataset, virtualDataset],
count: 2,
});
@@ -229,8 +227,7 @@ test('displays dataset type as Physical or Virtual', async () => {
test('displays database name in Database column', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -244,8 +241,7 @@ test('displays database name in Database column', async () => {
test('displays schema name in Schema column', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -257,8 +253,7 @@ test('displays schema name in Schema column', async () => {
test('displays last modified date in humanized format', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -283,7 +278,7 @@ test('sorting by Name column updates API call with sort parameter', async () =>
// Record initial calls
const initialCalls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Click Name header to sort
@@ -291,12 +286,14 @@ test('sorting by Name column updates API call with sort parameter', async () =>
// Wait for new API call
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(initialCalls);
});
// Verify latest call includes sort parameter
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
const latestCall = calls[calls.length - 1];
const { url } = latestCall;
@@ -317,17 +314,19 @@ test('sorting by Database column updates sort parameter', async () => {
});
const initialCalls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
await userEvent.click(databaseHeader);
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(initialCalls);
});
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
const { url } = calls[calls.length - 1];
expect(url).toMatch(/order_column|sort/);
});
@@ -345,17 +344,19 @@ test('sorting by Last modified column updates sort parameter', async () => {
});
const initialCalls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
await userEvent.click(modifiedHeader);
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(initialCalls);
});
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED);
const { url } = calls[calls.length - 1];
expect(url).toMatch(/order_column|sort/);
});
@@ -363,8 +364,7 @@ test('sorting by Last modified column updates sort parameter', async () => {
test('export button triggers handleResourceExport with dataset ID', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -392,8 +392,7 @@ test('delete button opens modal with dataset details', async () => {
setupDeleteMocks(dataset.id);
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -415,8 +414,7 @@ test('delete action successfully deletes dataset and refreshes list', async () =
const datasetToDelete = mockDatasets[0];
setupDeleteMocks(datasetToDelete.id);
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetToDelete],
count: 1,
});
@@ -442,7 +440,7 @@ test('delete action successfully deletes dataset and refreshes list', async () =
// Track API calls before confirm
const callsBefore = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Click confirm - find the danger button (last delete button in modal)
@@ -468,7 +466,7 @@ test('delete action successfully deletes dataset and refreshes list', async () =
// List refreshes
await waitFor(() => {
expect(
fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).length,
fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED).length,
).toBeGreaterThan(callsBefore);
});
});
@@ -477,8 +475,7 @@ test('delete action cancel closes modal without deleting', async () => {
const dataset = mockDatasets[0];
setupDeleteMocks(dataset.id);
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -518,8 +515,7 @@ test('duplicate action successfully duplicates virtual dataset', async () => {
const virtualDataset = mockDatasets[1]; // Virtual dataset (kind: 'virtual')
setupDuplicateMocks();
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [virtualDataset], count: 1 });
mockDatasetListEndpoints({ result: [virtualDataset], count: 1 });
renderDatasetList(mockAdminUser, {
addSuccessToast: mockAddSuccessToast,
@@ -542,7 +538,7 @@ test('duplicate action successfully duplicates virtual dataset', async () => {
// Track API calls before submit
const callsBefore = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Submit
@@ -564,7 +560,7 @@ test('duplicate action successfully duplicates virtual dataset', async () => {
// List refreshes
await waitFor(() => {
expect(
fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).length,
fetchMock.callHistory.calls(API_ENDPOINTS.DATASOURCE_COMBINED).length,
).toBeGreaterThan(callsBefore);
});
});
@@ -573,8 +569,7 @@ test('duplicate button visible only for virtual datasets', async () => {
const physicalDataset = mockDatasets[0]; // kind: 'physical'
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [physicalDataset, virtualDataset],
count: 2,
});
@@ -633,8 +628,7 @@ test('bulk select enables checkboxes', async () => {
}, 30000);
test('selecting all datasets shows correct count in toolbar', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: mockDatasets,
count: mockDatasets.length,
});
@@ -673,8 +667,7 @@ test('selecting all datasets shows correct count in toolbar', async () => {
}, 30000);
test('bulk export triggers export with selected IDs', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [mockDatasets[0]],
count: 1,
});
@@ -716,8 +709,7 @@ test('bulk export triggers export with selected IDs', async () => {
test('bulk delete opens confirmation modal', async () => {
setupBulkDeleteMocks();
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [mockDatasets[0]],
count: 1,
});
@@ -823,8 +815,7 @@ test('certified badge appears for certified datasets', async () => {
}),
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [certifiedDataset],
count: 1,
});
@@ -854,8 +845,7 @@ test('warning icon appears for datasets with warnings', async () => {
}),
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetWithWarning],
count: 1,
});
@@ -883,8 +873,7 @@ test('info tooltip appears for datasets with descriptions', async () => {
description: 'Sales data from Q4 2024',
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetWithDescription],
count: 1,
});
@@ -909,8 +898,7 @@ test('info tooltip appears for datasets with descriptions', async () => {
test('dataset name links to Explore page', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -930,8 +918,7 @@ test('dataset name links to Explore page', async () => {
test('physical dataset shows delete, export, and edit actions (no duplicate)', async () => {
const physicalDataset = mockDatasets[0]; // kind: 'physical'
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [physicalDataset],
count: 1,
});
@@ -962,8 +949,7 @@ test('physical dataset shows delete, export, and edit actions (no duplicate)', a
test('virtual dataset shows delete, export, edit, and duplicate actions', async () => {
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [virtualDataset], count: 1 });
mockDatasetListEndpoints({ result: [virtualDataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -992,8 +978,7 @@ test('edit action is enabled for dataset owner', async () => {
owners: [{ id: mockAdminUser.userId, username: 'admin' }],
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -1016,8 +1001,7 @@ test('edit action is disabled for non-owner', async () => {
owners: [{ id: 999, username: 'other_user' }], // Different user
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
// Use a non-admin user to test ownership check
const regularUser = {
@@ -1046,8 +1030,7 @@ test('all action buttons are clickable and enabled for admin user', async () =>
owners: [{ id: mockAdminUser.userId, username: 'admin' }],
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [virtualDataset], count: 1 });
mockDatasetListEndpoints({ result: [virtualDataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -1082,8 +1065,7 @@ test('all action buttons are clickable and enabled for admin user', async () =>
});
test('displays error when initial dataset fetch fails with 500', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
status: 500,
body: { message: 'Internal Server Error' },
});
@@ -1104,8 +1086,7 @@ test('displays error when initial dataset fetch fails with 500', async () => {
});
test('displays error when initial dataset fetch fails with 403 permission denied', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
status: 403,
body: { message: 'Access Denied' },
});
@@ -1119,9 +1100,9 @@ test('displays error when initial dataset fetch fails with 403 permission denied
expect(mockAddDangerToast).toHaveBeenCalled();
});
// Verify toast message contains the 403-specific "Access Denied" text
// Verify toast message contains the generic error text
const toastMessage = String(mockAddDangerToast.mock.calls[0][0]);
expect(toastMessage).toContain('Access Denied');
expect(toastMessage).toContain('An error occurred while fetching datasets');
// No dataset names from mockDatasets should appear in the document
mockDatasets.forEach(dataset => {
@@ -1373,7 +1354,7 @@ test('sort order persists after deleting a dataset', async () => {
// Record initial API calls count
const initialCalls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Click Name header to sort
@@ -1381,12 +1362,16 @@ test('sort order persists after deleting a dataset', async () => {
// Wait for new API call with sort parameter
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(initialCalls);
});
// Record the sort parameter from the API call after sorting
const callsAfterSort = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const callsAfterSort = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
const sortedUrl = callsAfterSort[callsAfterSort.length - 1].url;
expect(sortedUrl).toMatch(/order_column|sort/);
@@ -1406,7 +1391,7 @@ test('sort order persists after deleting a dataset', async () => {
// Record call count before delete to track refetch
const callsBeforeDelete = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
const confirmButton = within(modal)
@@ -1427,7 +1412,7 @@ test('sort order persists after deleting a dataset', async () => {
// Wait for list refetch to complete (prevents async cleanup error)
await waitFor(() => {
const currentCalls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
expect(currentCalls).toBeGreaterThan(callsBeforeDelete);
});
@@ -1452,8 +1437,7 @@ test('sort order persists after deleting a dataset', async () => {
// test. Component tests here focus on individual behaviors.
test('bulk selection clears when filter changes', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: mockDatasets,
count: mockDatasets.length,
});
@@ -1505,7 +1489,7 @@ test('bulk selection clears when filter changes', async () => {
// Record API call count before filter
const beforeFilterCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Wait for filter combobox to be ready before applying filter
@@ -1516,13 +1500,15 @@ test('bulk selection clears when filter changes', async () => {
// Wait for filter API call to complete
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(beforeFilterCallCount);
});
// Verify filter was applied by decoding URL payload
const urlAfterFilter = fetchMock.callHistory
.calls(API_ENDPOINTS.DATASETS)
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
.at(-1)?.url;
const risonAfterFilter = new URL(
urlAfterFilter!,
@@ -1557,7 +1543,7 @@ test('type filter API call includes correct filter parameter', async () => {
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply Type filter
@@ -1565,12 +1551,16 @@ test('type filter API call includes correct filter parameter', async () => {
// Wait for filter API call to complete
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(callsBeforeFilter);
});
// Verify the latest API call includes the Type filter
const url = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS).at(-1)?.url;
const url = fetchMock.callHistory
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
.at(-1)?.url;
expect(url).toContain('filters');
// searchParams.get() already URL-decodes, so pass directly to rison.decode
@@ -1603,7 +1593,7 @@ test('type filter persists after duplicating a dataset', async () => {
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Apply Type filter
@@ -1611,13 +1601,15 @@ test('type filter persists after duplicating a dataset', async () => {
// Wait for filter API call to complete
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(callsBeforeFilter);
});
// Verify filter is present by checking the latest API call
const urlAfterFilter = fetchMock.callHistory
.calls(API_ENDPOINTS.DATASETS)
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
.at(-1)?.url;
const risonAfterFilter = new URL(
urlAfterFilter!,
@@ -1637,7 +1629,7 @@ test('type filter persists after duplicating a dataset', async () => {
// Capture datasets API call count BEFORE any duplicate operations
const datasetsCallCountBeforeDuplicate = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Now duplicate the dataset
@@ -1673,14 +1665,14 @@ test('type filter persists after duplicating a dataset', async () => {
// Wait for datasets refetch to occur (proves duplicate triggered a refresh)
await waitFor(() => {
const datasetsCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDuplicate);
});
// Verify Type filter persisted in the NEW datasets API call after duplication
const urlAfterDuplicate = fetchMock.callHistory
.calls(API_ENDPOINTS.DATASETS)
.calls(API_ENDPOINTS.DATASOURCE_COMBINED)
.at(-1)?.url;
const risonAfterDuplicate = new URL(
urlAfterDuplicate!,
@@ -1715,8 +1707,7 @@ test('edit action shows error toast when dataset fetch fails', async () => {
],
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [ownedDataset], count: 1 });
mockDatasetListEndpoints({ result: [ownedDataset], count: 1 });
// Mock SupersetClient.get to fail for the specific dataset endpoint
jest.spyOn(SupersetClient, 'get').mockImplementation(async request => {
@@ -1759,8 +1750,7 @@ test('bulk export error shows toast and clears loading state', async () => {
// Mock handleResourceExport to throw an error
mockHandleResourceExport.mockRejectedValueOnce(new Error('Export failed'));
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [mockDatasets[0]],
count: 1,
});
@@ -1824,8 +1814,7 @@ test('bulk delete error shows toast without refreshing list', async () => {
body: { message: 'Bulk delete failed' },
});
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [mockDatasets[0]],
count: 1,
});
@@ -1901,8 +1890,7 @@ test('bulk select shows "N Selected (Virtual)" for virtual-only selection', asyn
// Use only virtual datasets
const virtualDatasets = mockDatasets.filter(d => d.kind === 'virtual');
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: virtualDatasets,
count: virtualDatasets.length,
});
@@ -1948,8 +1936,7 @@ test('bulk select shows "N Selected (Physical)" for physical-only selection', as
// Use only physical datasets
const physicalDatasets = mockDatasets.filter(d => d.kind === 'physical');
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: physicalDatasets,
count: physicalDatasets.length,
});
@@ -1999,8 +1986,7 @@ test('bulk select shows mixed count for virtual and physical selection', async (
mockDatasets.find(d => d.kind === 'virtual')!,
];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: mixedDatasets,
count: mixedDatasets.length,
});
@@ -2063,8 +2049,7 @@ test('delete modal shows affected dashboards with overflow for >10 items', async
title: `Dashboard ${i + 1}`,
}));
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
fetchMock.get(`glob:*/api/v1/dataset/${dataset.id}/related_objects*`, {
charts: { count: 0, result: [] },
@@ -2101,8 +2086,7 @@ test('delete modal shows affected dashboards with overflow for >10 items', async
test('delete modal hides affected dashboards section when count is zero', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
fetchMock.get(`glob:*/api/v1/dataset/${dataset.id}/related_objects*`, {
charts: { count: 2, result: [{ id: 1, slice_name: 'Chart 1' }] },
@@ -2140,8 +2124,7 @@ test('delete modal shows affected charts with overflow for >10 items', async ()
slice_name: `Chart ${i + 1}`,
}));
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
fetchMock.get(`glob:*/api/v1/dataset/${dataset.id}/related_objects*`, {
charts: { count: 12, result: manyCharts },

View File

@@ -27,7 +27,7 @@ import {
mockWriteUser,
mockExportOnlyUser,
mockDatasets,
API_ENDPOINTS,
mockDatasetListEndpoints,
} from './DatasetList.testHelpers';
// Increase default timeout for tests that involve multiple async operations
@@ -238,8 +238,7 @@ test('action buttons respect user permissions', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);
@@ -265,8 +264,7 @@ test('read-only user sees no delete or duplicate buttons in row', async () => {
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockReadOnlyUser);
@@ -301,8 +299,7 @@ test('write user sees edit, delete, and export actions', async () => {
owners: [{ id: mockWriteUser.userId, username: 'writeuser' }],
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockWriteUser);
@@ -337,8 +334,7 @@ test('export-only user has no Actions column (no write/duplicate permissions)',
const dataset = mockDatasets[0];
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockExportOnlyUser);
@@ -371,8 +367,7 @@ test('user with can_duplicate sees duplicate button only for virtual datasets',
const physicalDataset = mockDatasets[0]; // kind: 'physical'
const virtualDataset = mockDatasets[1]; // kind: 'virtual'
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [physicalDataset, virtualDataset],
count: 2,
});

View File

@@ -29,6 +29,7 @@ import {
mockExportOnlyUser,
mockDatasets,
mockApiError403,
mockDatasetListEndpoints,
API_ENDPOINTS,
RisonFilter,
} from './DatasetList.testHelpers';
@@ -68,13 +69,17 @@ test('shows loading state during initial data fetch', () => {
// Use fake timers to avoid leaving real timers running after test
jest.useFakeTimers();
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(
API_ENDPOINTS.DATASETS,
new Promise(resolve =>
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
),
const delayedResponse = new Promise(resolve =>
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
);
fetchMock.removeRoutes({
names: [
API_ENDPOINTS.DATASOURCE_COMBINED,
API_ENDPOINTS.DATASOURCE_COMBINED,
],
});
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
renderDatasetList(mockAdminUser);
@@ -87,13 +92,17 @@ test('maintains component structure during loading', () => {
// Use fake timers to avoid leaving real timers running after test
jest.useFakeTimers();
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(
API_ENDPOINTS.DATASETS,
new Promise(resolve =>
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
),
const delayedResponse = new Promise(resolve =>
setTimeout(() => resolve({ result: [], count: 0 }), 10000),
);
fetchMock.removeRoutes({
names: [
API_ENDPOINTS.DATASOURCE_COMBINED,
API_ENDPOINTS.DATASOURCE_COMBINED,
],
});
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, delayedResponse);
renderDatasetList(mockAdminUser);
@@ -214,8 +223,7 @@ test('handles datasets with missing fields and renders gracefully', async () =>
sql: null,
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetWithMissingFields],
count: 1,
});
@@ -241,8 +249,7 @@ test('handles datasets with missing fields and renders gracefully', async () =>
});
test('handles empty results (shows empty state)', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [], count: 0 });
mockDatasetListEndpoints({ result: [], count: 0 });
renderDatasetList(mockAdminUser);
@@ -254,7 +261,9 @@ test('makes correct initial API call on load', async () => {
renderDatasetList(mockAdminUser);
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(0);
});
});
@@ -263,7 +272,9 @@ test('API call includes correct page size', async () => {
renderDatasetList(mockAdminUser);
await waitFor(() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(0);
const { url } = calls[0];
expect(url).toContain('page_size');
@@ -278,7 +289,7 @@ test('typing in name filter updates input value and triggers API with decoded se
// Record initial API calls
const initialCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASETS,
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
// Type in search box and press Enter to trigger search
@@ -292,7 +303,9 @@ test('typing in name filter updates input value and triggers API with decoded se
// Wait for API call after Enter key press
await waitFor(
() => {
const calls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const calls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
expect(calls.length).toBeGreaterThan(initialCallCount);
// Get latest API call
@@ -346,8 +359,7 @@ test('toggling bulk select mode shows checkboxes', async () => {
}, 30000);
test('handles 500 error on initial load without crashing', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
throws: new Error('Internal Server Error'),
});
@@ -385,8 +397,7 @@ test('handles 403 error on _info endpoint and disables create actions', async ()
});
test('handles network timeout without crashing', async () => {
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
throws: new Error('Network timeout'),
});
@@ -414,7 +425,9 @@ test('component requires explicit mocks for all API endpoints', async () => {
await waitForDatasetsPageReady();
// Verify that critical endpoints were called and had mocks available
const newDatasetsCalls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS);
const newDatasetsCalls = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
);
const newInfoCalls = fetchMock.callHistory.calls(API_ENDPOINTS.DATASETS_INFO);
// These should have been called during render
@@ -446,8 +459,7 @@ test('renders datasets with certification data', async () => {
}),
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [certifiedDataset],
count: 1,
});
@@ -474,8 +486,7 @@ test('displays datasets with warning_markdown', async () => {
}),
};
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetWithWarning],
count: 1,
});
@@ -496,8 +507,7 @@ test('displays datasets with warning_markdown', async () => {
test('displays dataset with multiple owners', async () => {
const datasetWithOwners = mockDatasets[1]; // Has 2 owners: Jane Smith, Bob Jones
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetWithOwners],
count: 1,
});
@@ -518,8 +528,7 @@ test('displays dataset with multiple owners', async () => {
test('displays ModifiedInfo with humanized date', async () => {
const datasetWithModified = mockDatasets[0]; // changed_by_name: 'John Doe', changed_on: '1 day ago'
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, {
mockDatasetListEndpoints({
result: [datasetWithModified],
count: 1,
});
@@ -541,8 +550,7 @@ test('displays ModifiedInfo with humanized date', async () => {
test('dataset name links to Explore with correct explore_url', async () => {
const dataset = mockDatasets[0]; // explore_url: '/explore/?datasource=1__table'
fetchMock.removeRoutes({ names: [API_ENDPOINTS.DATASETS] });
fetchMock.get(API_ENDPOINTS.DATASETS, { result: [dataset], count: 1 });
mockDatasetListEndpoints({ result: [dataset], count: 1 });
renderDatasetList(mockAdminUser);

View File

@@ -318,6 +318,7 @@ export const mockApiError404 = {
export const API_ENDPOINTS = {
DATASETS_INFO: 'glob:*/api/v1/dataset/_info*',
DATASETS: 'glob:*/api/v1/dataset/?*',
DATASOURCE_COMBINED: 'glob:*/api/v1/datasource/?*',
DATASET_GET: 'glob:*/api/v1/dataset/[0-9]*',
DATASET_RELATED_OBJECTS: 'glob:*/api/v1/dataset/*/related_objects*',
DATASET_DELETE: 'glob:*/api/v1/dataset/[0-9]*',
@@ -499,6 +500,24 @@ export const assertOnlyExpectedCalls = (expectedEndpoints: string[]) => {
});
};
/**
* Helper to mock the dataset list endpoints.
* The component fetches from /api/v1/datasource/ (combined endpoint).
* Some tests also need the legacy /api/v1/dataset/ endpoint for
* other operations (delete, bulk delete) that still use it.
*/
export const mockDatasetListEndpoints = (response: Record<string, unknown>) => {
fetchMock.removeRoutes({
names: [API_ENDPOINTS.DATASETS, API_ENDPOINTS.DATASOURCE_COMBINED],
});
fetchMock.get(API_ENDPOINTS.DATASETS, response, {
name: API_ENDPOINTS.DATASETS,
});
fetchMock.get(API_ENDPOINTS.DATASOURCE_COMBINED, response, {
name: API_ENDPOINTS.DATASOURCE_COMBINED,
});
};
// MSW setup using fetch-mock (following ChartList pattern)
// Routes are named using the API_ENDPOINTS constant values so they can be
// removed by name using removeRoutes({ names: [API_ENDPOINTS.X] })
@@ -511,11 +530,10 @@ export const setupMocks = () => {
{ name: API_ENDPOINTS.DATASETS_INFO },
);
fetchMock.get(
API_ENDPOINTS.DATASETS,
{ result: mockDatasets, count: mockDatasets.length },
{ name: API_ENDPOINTS.DATASETS },
);
mockDatasetListEndpoints({
result: mockDatasets,
count: mockDatasets.length,
});
fetchMock.get(
API_ENDPOINTS.DATASET_FAVORITE_STATUS,

View File

@@ -17,9 +17,22 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { getExtensionsRegistry, SupersetClient } from '@superset-ui/core';
import {
getExtensionsRegistry,
SupersetClient,
isFeatureEnabled,
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';
import {
@@ -32,17 +45,20 @@ 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,
Loading,
List,
} from '@superset-ui/core/components';
import { DatasourceModal, GenericLink } from 'src/components';
import {
DatasourceModal,
GenericLink,
FacePile,
ImportModal as ImportModelsModal,
ModifiedInfo,
@@ -50,6 +66,7 @@ import {
ListViewFilterOperator as FilterOperator,
type ListViewProps,
type ListViewFilters,
type ListViewFetchDataConfig,
} from 'src/components';
import { Typography } from '@superset-ui/core/components/Typography';
import handleResourceExport from 'src/utils/export';
@@ -67,9 +84,20 @@ import {
CONFIRM_OVERWRITE_MESSAGE,
} from 'src/features/datasets/constants';
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';
import type { BootstrapData } from 'src/types/bootstrapTypes';
const extensionsRegistry = getExtensionsRegistry();
const DatasetDeleteRelatedExtension = extensionsRegistry.get(
@@ -115,22 +143,28 @@ const Actions = styled.div`
type Dataset = {
changed_by_name: string;
changed_by: string;
changed_by: Owner;
changed_on_delta_humanized: string;
database: {
id: string;
database_name: string;
};
kind: string;
} | null;
kind: 'physical' | 'virtual' | 'semantic_view';
source_type?: 'database' | 'semantic_layer';
explore_url: string;
id: number;
owners: Array<Owner>;
schema: string;
schema: string | null;
table_name: string;
description?: string | null;
cache_timeout?: number | null;
extra?: string | Record<string, any> | null;
sql?: string | null;
};
interface VirtualDataset extends Dataset {
extra: Record<string, any>;
kind: 'virtual';
extra: string | Record<string, any>;
sql: string;
}
@@ -152,17 +186,278 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const history = useHistory();
const theme = useTheme();
const {
state: {
loading,
resourceCount: datasetCount,
resourceCollection: datasets,
bulkSelectEnabled,
},
state: { bulkSelectEnabled },
hasPerm,
fetchData,
toggleBulkSelect,
refreshData,
} = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
} = useListViewResource<Dataset>(
'dataset',
datasetLabelLower(),
addDangerToast,
);
// Combined endpoint state
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [datasetCount, setDatasetCount] = useState(0);
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',
);
if (
sourceTypeFilter?.value &&
typeof sourceTypeFilter.value === 'object' &&
'value' in sourceTypeFilter.value
) {
return sourceTypeFilter.value.value as string;
}
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.
*
* 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 =
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 } : {}),
});
// 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);
})
.catch(() => {
addDangerToast(
t('An error occurred while fetching %s', datasetsLabelLower()),
);
})
.finally(() => {
setLoading(false);
});
},
[addDangerToast],
);
const refreshData = useCallback(() => {
if (lastFetchConfig) {
return fetchData(lastFetchConfig);
}
return undefined;
}, [lastFetchConfig, fetchData]);
const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
| (Dataset & {
@@ -178,6 +473,15 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] =
useState<VirtualDataset | null>(null);
const [svCurrentlyEditing, setSvCurrentlyEditing] = useState<Dataset | null>(
null,
);
const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
useState<Dataset | null>(null);
const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
useState(false);
const [importingDataset, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
@@ -192,7 +496,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
setSSHTunnelPrivateKeyPasswordFields,
] = useState<string[]>([]);
const PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = useSelector<any, boolean>(
const PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = useSelector<
BootstrapData,
boolean
>(
state =>
state.common?.conf?.PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET || false,
);
@@ -208,7 +515,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const handleDatasetImport = () => {
showImportModal(false);
refreshData();
addSuccessToast(t('Dataset imported'));
addSuccessToast(t('%s imported', datasetLabel()));
};
const canEdit = hasPerm('can_write');
@@ -246,7 +553,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(),
),
);
});
},
@@ -288,14 +598,42 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
await handleResourceExport('dataset', ids, () => {
setPreparingExport(false);
});
} catch (error) {
} 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],
);
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));
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue deleting %s: %s', tableName, errMsg),
),
),
);
};
const columns = useMemo(
() => [
{
@@ -315,7 +653,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
explore_url: exploreURL,
},
},
}: any) => {
}: CellProps<Dataset>) => {
let titleLink: JSX.Element;
if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
titleLink = (
@@ -331,7 +669,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
}
try {
const parsedExtra = JSON.parse(extra);
const parsedExtra =
typeof extra === 'string'
? JSON.parse(extra)
: (extra as Record<string, any> | null);
return (
<FlexRowContainer>
{parsedExtra?.certification && (
@@ -364,7 +705,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
row: {
original: { kind },
},
}: any) => <DatasetTypeLabel datasetType={kind} />,
}: CellProps<Dataset>) => <DatasetTypeLabel datasetType={kind} />,
Header: t('Type'),
accessor: 'kind',
disableSortBy: true,
@@ -372,12 +713,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
id: 'kind',
},
{
Header: t('Database'),
Cell: ({
row: {
original: { database },
},
}: CellProps<Dataset>) => database?.database_name || '-',
Header: databaseLabel(),
accessor: 'database.database_name',
size: 'xl',
id: 'database.database_name',
},
{
Cell: ({
row: {
original: { schema },
},
}: CellProps<Dataset>) => schema || '-',
Header: t('Schema'),
accessor: 'schema',
size: 'lg',
@@ -394,7 +745,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
row: {
original: { owners = [] },
},
}: any) => <FacePile users={owners} />,
}: CellProps<Dataset>) => <FacePile users={owners} />,
Header: t('Owners'),
id: 'owners',
disableSortBy: true,
@@ -408,7 +759,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
changed_by: changedBy,
},
},
}: any) => <ModifiedInfo date={changedOn} user={changedBy} />,
}: CellProps<Dataset>) => (
<ModifiedInfo date={changedOn} user={changedBy} />
),
Header: t('Last modified'),
accessor: 'changed_on_delta_humanized',
size: 'xl',
@@ -421,16 +774,70 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
id: 'sql',
},
{
Cell: ({ row: { original } }: any) => {
// Verify owner or isAdmin
accessor: 'source_type',
hidden: true,
disableSortBy: true,
id: 'source_type',
},
{
Cell: ({ row: { original } }: CellProps<Dataset>) => {
const isSemanticView = original.kind === 'semantic_view';
// Semantic view: show edit and delete buttons
if (isSemanticView) {
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={() => 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>
);
}
// Dataset: full set of actions
const allowEdit =
original.owners.map((o: Owner) => o.id).includes(user.userId) ||
isUserAdmin(user);
original.owners
.map((o: Owner) => o.id)
.includes(Number(user.userId)) || isUserAdmin(user);
const handleEdit = () => openDatasetEditModal(original);
const handleDelete = () => openDatasetDeleteModal(original);
const handleExport = () => handleBulkDatasetExport([original]);
const handleDuplicate = () => openDatasetDuplicateModal(original);
const handleDuplicate = () => {
if (original.kind === 'virtual' && original.sql) {
openDatasetDuplicateModal(original as VirtualDataset);
}
};
if (!canEdit && !canDelete && !canExport && !canDuplicate) {
return null;
}
@@ -536,6 +943,25 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const filterTypes: ListViewFilters = useMemo(
() => [
...(isFeatureEnabled(FeatureFlag.SemanticLayers)
? [
{
Header: t('Source'),
key: 'source_type',
id: 'source_type',
input: 'select' as const,
operator: FilterOperator.Equals,
unfilteredLabel: t('All'),
selects: [
{ label: t('Database'), value: 'database' },
{ label: t('Semantic Layer'), value: 'semantic_layer' },
],
onFilterUpdate: (option: any) => {
cascadeClear('source', option?.value);
},
},
]
: []),
{
Header: t('Name'),
key: 'search',
@@ -543,34 +969,60 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
input: 'search',
operator: FilterOperator.Contains,
},
...(isFeatureEnabled(FeatureFlag.SemanticLayers)
? [
{
Header: t('Type'),
key: 'sql',
id: 'sql',
input: 'select' as const,
operator: FilterOperator.DatasetIsNullOrEmpty,
unfilteredLabel: 'All',
selects: [
...(currentSourceFilter !== 'semantic_layer'
? [
{ label: t('Physical'), value: true },
{ label: t('Virtual'), value: false },
]
: []),
...(currentSourceFilter !== 'database'
? [{ label: t('Semantic View'), value: 'semantic_view' }]
: []),
],
onFilterUpdate: (option: any) => {
currentTypeFilter.current = option?.value;
cascadeClear('type', option?.value);
},
},
]
: [
{
Header: t('Type'),
key: 'sql',
id: 'sql',
input: 'select' as const,
operator: FilterOperator.DatasetIsNullOrEmpty,
unfilteredLabel: 'All',
selects: [
{ label: t('Physical'), value: true },
{ label: t('Virtual'), value: false },
],
},
]),
{
Header: t('Type'),
key: 'sql',
id: 'sql',
input: 'select',
operator: FilterOperator.DatasetIsNullOrEmpty,
unfilteredLabel: 'All',
selects: [
{ label: t('Virtual'), value: false },
{ label: t('Physical'), value: true },
],
},
{
Header: t('Database'),
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 datasets: %s', errMsg),
),
),
fetchSelects: fetchConnectionOptions,
paginate: true,
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
onFilterUpdate: (option: any) => {
currentConnectionFilter.current = option?.value;
cascadeClear('connection', option?.value);
},
},
{
Header: t('Schema'),
@@ -600,7 +1052,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,
),
),
@@ -635,7 +1088,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,
),
),
@@ -645,12 +1099,12 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
},
],
[user],
[user, currentSourceFilter],
);
const menuData: SubMenuProps = {
activeChild: 'Datasets',
name: t('Datasets'),
name: datasetsLabel(),
};
const buttonArr: Array<ButtonProps> = [];
@@ -660,7 +1114,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
name: (
<Tooltip
id="import-tooltip"
title={t('Import datasets')}
title={t('Import %s', datasetsLabelLower())}
placement="bottomRight"
>
<Icons.DownloadOutlined
@@ -684,14 +1138,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: datasetLabel(),
onClick: () => {
history.push('/dataset/add/');
},
buttonStyle: 'primary',
});
}
}
menuData.buttons = buttonArr;
@@ -726,26 +1224,54 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
};
const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
SupersetClient.delete({
endpoint: `/api/v1/dataset/?q=${rison.encode(
datasetsToDelete.map(({ id }) => id),
)}`,
}).then(
({ json = {} }) => {
refreshData();
addSuccessToast(json.message);
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue deleting the selected datasets: %s', errMsg),
),
),
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(
SupersetClient.delete({
endpoint: `/api/v1/semantic_view/?q=${rison.encode(
semanticViews.map(({ id }) => id),
)}`,
}),
);
}
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));
} else {
addDangerToast(
t('There was an issue deleting the selected %s', datasetsLabelLower()),
);
}
});
};
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({
@@ -761,7 +1287,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,
),
),
),
);
@@ -775,7 +1305,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.',
@@ -881,7 +1411,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
}}
onHide={closeDatasetDeleteModal}
open
title={t('Delete Dataset?')}
title={t('Delete %s?', datasetLabel())}
/>
)}
{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 && (
@@ -893,14 +1435,30 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
/>
)}
<DuplicateDatasetModal
dataset={datasetCurrentlyDuplicating}
dataset={datasetCurrentlyDuplicating as DatasetType | null}
onHide={closeDatasetDuplicateModal}
onDuplicate={handleDatasetDuplicate}
/>
<SemanticViewEditModal
show={!!svCurrentlyEditing}
onHide={() => setSvCurrentlyEditing(null)}
onSave={refreshData}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
semanticView={svCurrentlyEditing}
/>
<AddSemanticViewModal
show={showAddSemanticViewModal}
onHide={() => setShowAddSemanticViewModal(false)}
onSuccess={refreshData}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
/>
<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}
>
@@ -931,6 +1489,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
pageSize={PAGE_SIZE}
fetchData={fetchData}
filters={filterTypes}
filtersRef={filtersRef}
loading={loading}
initialSort={initialSort}
bulkActions={bulkActions}
@@ -983,7 +1542,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
<ImportModelsModal
resourceName="dataset"
resourceLabel={t('dataset')}
resourceLabel={datasetLabelLower()}
passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE}
confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE}
addDangerToast={addDangerToast}

View File

@@ -25,11 +25,18 @@ export default interface Dataset {
database: {
id: string;
database_name: string;
};
} | null;
kind: string;
source_type?: 'database' | 'semantic_layer';
explore_url: string;
id: number;
owners: Array<Owner>;
schema: string;
schema: string | null;
catalog?: string | null;
table_name: string;
description?: string | null;
cache_timeout?: number | null;
default_endpoint?: string | null;
is_sqllab_view?: boolean;
is_managed_externally?: boolean;
}

View File

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

View File

View File

@@ -0,0 +1,211 @@
# 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.
"""Command for the combined dataset + semantic view list endpoint."""
from __future__ import annotations
import logging
from typing import Any, cast
from sqlalchemy import union_all
from superset.commands.base import BaseCommand
from superset.connectors.sqla.models import SqlaTable
from superset.daos.datasource import DatasourceDAO
from superset.datasource.schemas import DatasetListSchema, SemanticViewListSchema
from superset.semantic_layers.models import SemanticView
logger = logging.getLogger(__name__)
_dataset_schema = DatasetListSchema()
_semantic_view_schema = SemanticViewListSchema()
class GetCombinedDatasourceListCommand(BaseCommand):
"""
Fetch and serialize a paginated, combined list of datasets and semantic views.
Callers are responsible for checking access permissions before constructing
this command and for passing the appropriate ``can_read_*`` flags.
"""
def __init__(
self,
args: dict[str, Any],
can_read_datasets: bool,
can_read_semantic_views: bool,
) -> None:
self._args = args
self._can_read_datasets = can_read_datasets
self._can_read_semantic_views = can_read_semantic_views
def run(self) -> dict[str, Any]:
self.validate()
page = self._args.get("page", 0)
page_size = self._args.get("page_size", 25)
order_column = self._args.get("order_column", "changed_on")
order_direction = self._args.get("order_direction", "desc")
filters = self._args.get("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, database_id)
sv_q = DatasourceDAO.build_semantic_view_query(name_filter, semantic_layer_uuid)
if source_type == "database":
combined = ds_q.subquery()
elif source_type == "semantic_layer":
combined = sv_q.subquery()
else:
combined = union_all(ds_q, sv_q).subquery()
total_count, rows = DatasourceDAO.paginate_combined_query(
combined, order_column, order_direction, page, page_size
)
datasets_map = DatasourceDAO.fetch_datasets_by_ids(
[r.item_id for r in rows if r.source_type == "database"]
)
sv_map = DatasourceDAO.fetch_semantic_views_by_ids(
[r.item_id for r in rows if r.source_type == "semantic_layer"]
)
result: list[dict[str, Any]] = []
for row in rows:
if row.source_type == "database":
ds_obj = cast(SqlaTable | None, datasets_map.get(row.item_id))
if ds_obj:
result.append(_dataset_schema.dump(ds_obj))
else:
sv_obj = cast(SemanticView | None, sv_map.get(row.item_id))
if sv_obj:
result.append(_semantic_view_schema.dump(sv_obj))
return {"count": total_count, "result": result}
def validate(self) -> None:
pass # access checks are performed by the caller (API layer)
def _resolve_source_type(
self,
source_type: str,
sql_filter: bool | None,
type_filter: str | None,
) -> str:
"""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"
# 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 (only reached when source_type="all")
if type_filter == "semantic_view":
return "semantic_layer"
return source_type
@staticmethod
def _parse_filters(
filters: list[dict[str, Any]],
) -> 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
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")
opr = f.get("opr")
value = f.get("value")
if col == "source_type":
source_type = value or "all"
elif col == "table_name" and f.get("opr") == "ct":
name_filter = value
elif col == "sql":
if opr == "dataset_is_null_or_empty" and value == "semantic_view":
type_filter = "semantic_view"
elif opr == "dataset_is_null_or_empty" and isinstance(value, bool):
sql_filter = value
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,
)

View File

@@ -124,7 +124,11 @@ class GetExploreCommand(BaseCommand, ABC):
security_manager.raise_for_access(datasource=datasource)
viz_type = form_data.get("viz_type")
if not viz_type and datasource and datasource.default_endpoint:
if (
not viz_type
and datasource
and getattr(datasource, "default_endpoint", None)
):
raise WrongEndpointError(redirect=datasource.default_endpoint)
form_data["datasource"] = (

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,104 @@
# 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,
SemanticLayerNotFoundError,
SemanticViewCreateFailedError,
)
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
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()
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:
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"])
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"
)

View File

@@ -0,0 +1,115 @@
# 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 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
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()
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()
try:
security_manager.raise_for_ownership(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
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()
for model in self._models:
try:
security_manager.raise_for_ownership(model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex

View File

@@ -0,0 +1,76 @@
# 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 flask_babel import lazy_gettext as _
from superset.commands.exceptions import (
CommandException,
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
ForbiddenError,
UpdateFailedError,
)
class SemanticViewNotFoundError(CommandException):
status = 404
message = _("Semantic view does not exist")
class SemanticViewForbiddenError(ForbiddenError):
message = _("Changing this semantic view is forbidden")
class SemanticViewInvalidError(CommandInvalidError):
message = _("Semantic view parameters are invalid.")
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.")
class SemanticViewCreateFailedError(CreateFailedError):
message = _("Semantic view could not be created.")
class SemanticViewDeleteFailedError(DeleteFailedError):
message = _("Semantic view could not be deleted.")

View File

@@ -0,0 +1,126 @@
# 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 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 SemanticLayerDAO, SemanticViewDAO
from superset.exceptions import SupersetSecurityException
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
logger = logging.getLogger(__name__)
class UpdateSemanticViewCommand(BaseCommand):
def __init__(self, model_id: int, data: dict[str, Any]):
self._model_id = model_id
self._properties = data.copy()
self._model: SemanticView | None = None
@transaction(
on_error=partial(
on_error,
catches=(SQLAlchemyError, ValueError),
reraise=SemanticViewUpdateFailedError,
)
)
def run(self) -> Model:
self.validate()
assert self._model
return SemanticViewDAO.update(self._model, attributes=self._properties)
def validate(self) -> None:
self._model = SemanticViewDAO.find_by_id(self._model_id)
if not self._model:
raise SemanticViewNotFoundError()
try:
security_manager.raise_for_ownership(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
name = self._properties.get("name", self._model.name)
layer_uuid = str(self._model.semantic_layer_uuid)
configuration = self._properties.get(
"configuration",
json.loads(self._model.configuration),
)
if not SemanticViewDAO.validate_update_uniqueness(
view_uuid=str(self._model.uuid),
name=name,
layer_uuid=layer_uuid,
configuration=configuration,
):
raise ValueError(
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
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:
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)

View File

@@ -566,6 +566,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# can_copy_clipboard) instead of the single can_csv permission
# @lifecycle: development
"GRANULAR_EXPORT_CONTROLS": False,
# Enable semantic layers and show semantic views alongside datasets
# @lifecycle: development
"SEMANTIC_LAYERS": False,
# Enables advanced data type support
# @lifecycle: development
"ENABLE_ADVANCED_DATA_TYPES": False,

View File

@@ -108,6 +108,8 @@ from superset.sql.parse import Table
from superset.superset_typing import (
AdhocColumn,
AdhocMetric,
DatasetColumnData,
DatasetMetricData,
ExplorableData,
Metric,
QueryObjectDict,
@@ -447,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,
@@ -464,8 +467,8 @@ class BaseDatasource(
# sqla-specific
"sql": self.sql,
# one to many
"columns": [o.data for o in self.columns],
"metrics": [o.data for o in self.metrics],
"columns": [cast(DatasetColumnData, o.data) for o in self.columns],
"metrics": [cast(DatasetMetricData, o.data) for o in self.metrics],
"folders": self.folders,
# TODO deprecate, move logic to JS
"order_by_choices": self.order_by_choices,

View File

@@ -229,6 +229,40 @@ def inject_model_session_implementation() -> None:
core_models_module.get_session = get_session
def inject_semantic_layer_implementations() -> None:
"""
Replace abstract semantic layer decorator in
superset_core.semantic_layers.decorators with a concrete implementation
that registers classes in the contributions registry.
"""
import superset_core.semantic_layers.decorators as core_sl_module
import superset.extensions.context as context_module
from superset.semantic_layers.registry import registry
def semantic_layer_impl(
id: str,
name: str,
description: str | None = None,
) -> Callable[[Any], Any]:
def decorator(cls: Any) -> Any:
if context := context_module.get_current_extension_context():
manifest = context.manifest
prefixed_id = f"extensions.{manifest.publisher}.{manifest.name}.{id}"
else:
prefixed_id = id
cls.name = name
cls.description = description
cls._semantic_layer_id = prefixed_id
registry[prefixed_id] = cls
return cls
return decorator
core_sl_module.semantic_layer = semantic_layer_impl # type: ignore[assignment]
def initialize_core_api_dependencies() -> None:
"""
Initialize all dependency injections for the superset-core API.
@@ -242,3 +276,4 @@ def initialize_core_api_dependencies() -> None:
inject_query_implementations()
inject_task_implementations()
inject_rest_api_implementations()
inject_semantic_layer_implementations()

View File

@@ -17,9 +17,14 @@
import logging
import uuid
from typing import Union
from typing import Any, Union
from superset import db
from sqlalchemy import and_, func, literal, or_, select
from sqlalchemy.orm import joinedload
from sqlalchemy.sql import Select
from superset import db, security_manager
from superset.connectors.sqla import models as sqla_models
from superset.connectors.sqla.models import SqlaTable
from superset.daos.base import BaseDAO
from superset.daos.exceptions import (
@@ -28,11 +33,17 @@ from superset.daos.exceptions import (
DatasourceValueIsIncorrect,
)
from superset.models.sql_lab import Query, SavedQuery
from superset.semantic_layers.models import SemanticView
from superset.utils.core import DatasourceType
from superset.utils.filters import get_dataset_access_filters
logger = logging.getLogger(__name__)
Datasource = Union[SqlaTable, Query, SavedQuery]
Datasource = Union[SqlaTable, Query, SavedQuery, SemanticView]
def _escape_ilike_fragment(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
class DatasourceDAO(BaseDAO[Datasource]):
@@ -40,6 +51,7 @@ class DatasourceDAO(BaseDAO[Datasource]):
DatasourceType.TABLE: SqlaTable,
DatasourceType.QUERY: Query,
DatasourceType.SAVEDQUERY: SavedQuery,
DatasourceType.SEMANTIC_VIEW: SemanticView,
}
@classmethod
@@ -78,3 +90,125 @@ class DatasourceDAO(BaseDAO[Datasource]):
raise DatasourceNotFound()
return datasource
@staticmethod
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(
SqlaTable.id.label("item_id"),
literal("database").label("source_type"),
SqlaTable.changed_on,
SqlaTable.table_name,
).select_from(SqlaTable.__table__)
if not security_manager.can_access_all_datasources():
ds_q = ds_q.join(
sqla_models.Database,
sqla_models.Database.id == SqlaTable.database_id,
)
ds_q = ds_q.where(get_dataset_access_filters(SqlaTable))
if name_filter:
escaped = _escape_ilike_fragment(name_filter)
ds_q = ds_q.where(SqlaTable.table_name.ilike(f"%{escaped}%", escape="\\"))
if sql_filter is not None:
if sql_filter:
ds_q = ds_q.where(or_(SqlaTable.sql.is_(None), SqlaTable.sql == ""))
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,
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"),
SemanticView.changed_on,
SemanticView.name.label("table_name"),
).select_from(SemanticView.__table__)
if name_filter:
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
def paginate_combined_query(
combined: Any,
order_column: str,
order_direction: str,
page: int,
page_size: int,
) -> tuple[int, list[Any]]:
"""Count, sort, and paginate the combined dataset/semantic-view query."""
sort_col_map = {
"changed_on": "changed_on",
"changed_on_delta_humanized": "changed_on",
"table_name": "table_name",
}
if order_column not in sort_col_map:
raise ValueError(f"Invalid order column: {order_column}")
sort_col_name = sort_col_map[order_column]
total_count = (
db.session.execute(select(func.count()).select_from(combined)).scalar() or 0
)
sort_col = combined.c[sort_col_name]
ordered_col = sort_col.desc() if order_direction == "desc" else sort_col.asc()
rows = db.session.execute(
select(combined.c.item_id, combined.c.source_type)
.order_by(ordered_col)
.offset(page * page_size)
.limit(page_size)
).fetchall()
return total_count, rows
@staticmethod
def fetch_datasets_by_ids(ids: list[int]) -> dict[int, SqlaTable]:
"""Fetch SqlaTable objects by id with relationships eager-loaded."""
if not ids:
return {}
objs = (
db.session.query(SqlaTable)
.options(
joinedload(SqlaTable.database),
joinedload(SqlaTable.owners),
joinedload(SqlaTable.changed_by),
)
.filter(SqlaTable.id.in_(ids))
.all()
)
return {obj.id: obj for obj in objs}
@staticmethod
def fetch_semantic_views_by_ids(ids: list[int]) -> dict[int, SemanticView]:
"""Fetch SemanticView objects by id with relationships eager-loaded."""
if not ids:
return {}
objs = (
db.session.query(SemanticView)
.options(joinedload(SemanticView.changed_by))
.filter(SemanticView.id.in_(ids))
.all()
)
return {obj.id: obj for obj in objs}

View File

@@ -0,0 +1,201 @@
# 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.
"""DAOs for semantic layer models."""
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(BaseDAO[SemanticLayer], AbstractSemanticLayerDAO):
"""
Data Access Object for SemanticLayer model.
"""
# SemanticLayer uses uuid as the primary key
id_column_name = "uuid"
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, StatementError):
return None
@classmethod
def find_all(cls, skip_base_filter: bool = False) -> list[SemanticLayer]:
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:
"""
Validate that semantic layer name is unique.
:param name: Semantic layer name
:return: True if name is unique, False otherwise
"""
query = db.session.query(SemanticLayer).filter(SemanticLayer.name == name)
return not db.session.query(query.exists()).scalar()
@classmethod
def validate_update_uniqueness(cls, layer_uuid: str, name: str) -> bool:
"""
Validate that semantic layer name is unique for updates.
:param layer_uuid: UUID of the semantic layer being updated
:param name: New name to validate
:return: True if name is unique, False otherwise
"""
query = db.session.query(SemanticLayer).filter(
SemanticLayer.name == name,
SemanticLayer.uuid != layer_uuid,
)
return not db.session.query(query.exists()).scalar()
@classmethod
def find_by_name(cls, name: str) -> SemanticLayer | None:
"""
Find semantic layer by name.
:param name: Semantic layer name
:return: SemanticLayer instance or None
"""
return (
db.session.query(SemanticLayer)
.filter(SemanticLayer.name == name)
.one_or_none()
)
@classmethod
def get_semantic_views(cls, layer_uuid: str) -> list[SemanticView]:
"""
Get all semantic views for a semantic layer.
:param layer_uuid: UUID of the semantic layer
:return: List of SemanticView instances
"""
return (
db.session.query(SemanticView)
.filter(SemanticView.semantic_layer_uuid == layer_uuid)
.all()
)
class SemanticViewDAO(BaseDAO[SemanticView], AbstractSemanticViewDAO):
"""Data Access Object for SemanticView model."""
model_cls = SemanticView
@classmethod
def validate_uniqueness(
cls,
name: str,
layer_uuid: str,
configuration: dict[str, Any],
) -> bool:
"""
Validate that view is unique within a semantic layer.
Uniqueness is determined by name, layer, and configuration.
The configuration column is encrypted (non-deterministic
ciphertext), so it cannot be compared at the DB level. Instead,
we filter by name + layer in SQL and compare decrypted
configuration dicts in Python.
:param name: View name
:param layer_uuid: UUID of the semantic layer
:param configuration: Configuration dict to compare
:return: True if unique, False otherwise
"""
candidates = (
db.session.query(SemanticView)
.filter(
SemanticView.name == name,
SemanticView.semantic_layer_uuid == layer_uuid,
)
.all()
)
return not any(json.loads(c.configuration) == configuration for c in candidates)
@classmethod
def validate_update_uniqueness(
cls,
view_uuid: str,
name: str,
layer_uuid: str,
configuration: dict[str, Any],
) -> bool:
"""
Validate that view is unique within a semantic layer for updates.
Same logic as ``validate_uniqueness`` but excludes the view
being updated.
:param view_uuid: UUID of the view being updated
:param name: New name to validate
:param layer_uuid: UUID of the semantic layer
:param configuration: Configuration dict to compare
:return: True if unique, False otherwise
"""
candidates = (
db.session.query(SemanticView)
.filter(
SemanticView.name == name,
SemanticView.semantic_layer_uuid == layer_uuid,
SemanticView.uuid != view_uuid,
)
.all()
)
return not any(json.loads(c.configuration) == configuration for c in candidates)
@classmethod
def find_by_name(cls, name: str, layer_uuid: str) -> SemanticView | None:
"""
Find semantic view by name within a semantic layer.
:param name: View name
:param layer_uuid: UUID of the semantic layer
:return: SemanticView instance or None
"""
return (
db.session.query(SemanticView)
.filter(
SemanticView.name == name,
SemanticView.semantic_layer_uuid == layer_uuid,
)
.one_or_none()
)

View File

@@ -15,11 +15,14 @@
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Any
from flask import current_app as app, request
from flask_appbuilder.api import expose, protect, safe
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.api.schemas import get_list_schema
from superset import event_logger
from superset import event_logger, is_feature_enabled, security_manager
from superset.commands.datasource.list import GetCombinedDatasourceListCommand
from superset.connectors.sqla.models import BaseDatasource
from superset.daos.datasource import DatasourceDAO
from superset.daos.exceptions import DatasourceNotFound, DatasourceTypeNotSupportedError
@@ -303,3 +306,53 @@ class DatasourceRestApi(BaseSupersetApi):
f"Invalid expression type: {expression_type}. "
f"Valid types are: column, metric, where, having"
) from None
@expose("/", 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__}.combined_list",
log_to_statsd=False,
)
def combined_list(self, **kwargs: Any) -> FlaskResponse:
"""List datasets and semantic views combined.
---
get:
summary: List datasets and semantic views combined
parameters:
- in: query
name: q
content:
application/json:
schema:
$ref: '#/components/schemas/get_list_schema'
responses:
200:
description: Combined list of datasets and semantic views
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
500:
$ref: '#/components/responses/500'
"""
can_read_datasets = security_manager.can_access("can_read", "Dataset")
can_read_sv = is_feature_enabled(
"SEMANTIC_LAYERS"
) and security_manager.can_access("can_read", "SemanticView")
if not can_read_datasets and not can_read_sv:
return self.response(403, message="Access denied")
try:
result = GetCombinedDatasourceListCommand(
args=kwargs.get("rison", {}),
can_read_datasets=can_read_datasets,
can_read_semantic_views=can_read_sv,
).run()
except ValueError as ex:
return self.response(400, message=str(ex))
return self.response(200, **result)

View File

@@ -0,0 +1,147 @@
# 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.
"""Marshmallow schemas for the combined datasource list endpoint."""
from __future__ import annotations
from marshmallow import fields, Schema
from superset.connectors.sqla.models import SqlaTable
from superset.semantic_layers.models import SemanticView
class _ChangedBySchema(Schema):
first_name = fields.String()
last_name = fields.String()
class _OwnerSchema(Schema):
id = fields.Integer()
first_name = fields.String()
last_name = fields.String()
class _DatabaseSchema(Schema):
id = fields.Integer()
database_name = fields.String()
class DatasetListSchema(Schema):
"""Serializes a SqlaTable ORM object for the combined list response."""
id = fields.Integer()
uuid = fields.Method("get_uuid")
table_name = fields.String()
kind = fields.String()
source_type = fields.Constant("database")
description = fields.String(allow_none=True)
explore_url = fields.String()
database = fields.Method("get_database")
catalog = fields.String(allow_none=True)
schema = fields.String(allow_none=True)
sql = fields.String(allow_none=True)
extra = fields.Raw(allow_none=True)
default_endpoint = fields.String(allow_none=True)
is_sqllab_view = fields.Boolean(allow_none=True)
is_managed_externally = fields.Boolean(allow_none=True)
owners = fields.Method("get_owners")
changed_by_name = fields.String()
changed_by = fields.Method("get_changed_by")
changed_on_delta_humanized = fields.Method("get_changed_on_delta_humanized")
changed_on_utc = fields.Method("get_changed_on_utc")
def get_uuid(self, obj: SqlaTable) -> str:
return str(obj.uuid)
def get_database(self, obj: SqlaTable) -> dict[str, object] | None:
if not obj.database:
return None
return _DatabaseSchema().dump(
{"id": obj.database_id, "database_name": obj.database.database_name}
)
def get_owners(self, obj: SqlaTable) -> list[dict[str, object]]:
return _OwnerSchema(many=True).dump(
[
{"id": o.id, "first_name": o.first_name, "last_name": o.last_name}
for o in obj.owners
]
)
def get_changed_by(self, obj: SqlaTable) -> dict[str, object] | None:
if not obj.changed_by:
return None
return _ChangedBySchema().dump(
{
"first_name": obj.changed_by.first_name,
"last_name": obj.changed_by.last_name,
}
)
def get_changed_on_delta_humanized(self, obj: SqlaTable) -> str:
return obj.changed_on_delta_humanized()
def get_changed_on_utc(self, obj: SqlaTable) -> str:
return obj.changed_on_utc()
class SemanticViewListSchema(Schema):
"""Serializes a SemanticView ORM object for the combined list response."""
id = fields.Integer()
uuid = fields.Method("get_uuid")
table_name = fields.Method("get_table_name")
kind = fields.Constant("semantic_view")
source_type = fields.Constant("semantic_layer")
description = fields.String(allow_none=True)
explore_url = fields.String()
database = fields.Constant(None)
catalog = fields.Constant(None)
schema = fields.Constant(None)
sql = fields.Constant(None)
extra = fields.Constant(None)
default_endpoint = fields.Constant(None)
is_sqllab_view = fields.Constant(False)
is_managed_externally = fields.Constant(False)
owners = fields.Constant([])
changed_by_name = fields.String()
changed_by = fields.Method("get_changed_by")
changed_on_delta_humanized = fields.Method("get_changed_on_delta_humanized")
changed_on_utc = fields.Method("get_changed_on_utc")
cache_timeout = fields.Integer(allow_none=True)
def get_uuid(self, obj: SemanticView) -> str:
return str(obj.uuid)
def get_table_name(self, obj: SemanticView) -> str:
return obj.name
def get_changed_by(self, obj: SemanticView) -> dict[str, object] | None:
if not obj.changed_by:
return None
return _ChangedBySchema().dump(
{
"first_name": obj.changed_by.first_name,
"last_name": obj.changed_by.last_name,
}
)
def get_changed_on_delta_humanized(self, obj: SemanticView) -> str:
return obj.changed_on_delta_humanized()
def get_changed_on_utc(self, obj: SemanticView) -> str:
return obj.changed_on_utc()

View File

@@ -53,6 +53,130 @@ class TimeGrainDict(TypedDict):
duration: str | None
@runtime_checkable
class MetricMetadata(Protocol):
"""
Protocol for metric metadata objects.
Represents a metric that's available on an explorable data source.
Metrics contain SQL expressions or references to semantic layer measures.
Attributes:
metric_name: Unique identifier for the metric
expression: SQL expression or reference for calculating the metric
verbose_name: Human-readable name for display in the UI
description: Description of what the metric represents
d3format: D3 format string for formatting numeric values
currency: Currency configuration for the metric (JSON object)
warning_text: Warning message to display when using this metric
certified_by: Person or entity that certified this metric
certification_details: Details about the certification
"""
@property
def metric_name(self) -> str:
"""Unique identifier for the metric."""
@property
def expression(self) -> str:
"""SQL expression or reference for calculating the metric."""
@property
def verbose_name(self) -> str | None:
"""Human-readable name for display in the UI."""
@property
def description(self) -> str | None:
"""Description of what the metric represents."""
@property
def d3format(self) -> str | None:
"""D3 format string for formatting numeric values."""
@property
def currency(self) -> dict[str, Any] | None:
"""Currency configuration for the metric (JSON object)."""
@property
def warning_text(self) -> str | None:
"""Warning message to display when using this metric."""
@property
def certified_by(self) -> str | None:
"""Person or entity that certified this metric."""
@property
def certification_details(self) -> str | None:
"""Details about the certification."""
@runtime_checkable
class ColumnMetadata(Protocol):
"""
Protocol for column metadata objects.
Represents a column/dimension that's available on an explorable data source.
Used for grouping, filtering, and dimension-based analysis.
Attributes:
column_name: Unique identifier for the column
type: SQL data type of the column (e.g., 'VARCHAR', 'INTEGER', 'DATETIME')
is_dttm: Whether this column represents a date or time value
verbose_name: Human-readable name for display in the UI
description: Description of what the column represents
groupby: Whether this column is allowed for grouping/aggregation
filterable: Whether this column can be used in filters
expression: SQL expression if this is a calculated column
python_date_format: Python datetime format string for temporal columns
advanced_data_type: Advanced data type classification
extra: Additional metadata stored as JSON
"""
@property
def column_name(self) -> str:
"""Unique identifier for the column."""
@property
def type(self) -> str:
"""SQL data type of the column."""
@property
def is_dttm(self) -> bool:
"""Whether this column represents a date or time value."""
@property
def verbose_name(self) -> str | None:
"""Human-readable name for display in the UI."""
@property
def description(self) -> str | None:
"""Description of what the column represents."""
@property
def groupby(self) -> bool:
"""Whether this column is allowed for grouping/aggregation."""
@property
def filterable(self) -> bool:
"""Whether this column can be used in filters."""
@property
def expression(self) -> str | None:
"""SQL expression if this is a calculated column."""
@property
def python_date_format(self) -> str | None:
"""Python datetime format string for temporal columns."""
@property
def advanced_data_type(self) -> str | None:
"""Advanced data type classification."""
@property
def extra(self) -> str | None:
"""Additional metadata stored as JSON."""
@runtime_checkable
class Explorable(Protocol):
"""
@@ -144,7 +268,7 @@ class Explorable(Protocol):
"""
@property
def metrics(self) -> list[Any]:
def metrics(self) -> list[MetricMetadata]:
"""
List of metric metadata objects.
@@ -159,7 +283,7 @@ class Explorable(Protocol):
# TODO: rename to dimensions
@property
def columns(self) -> list[Any]:
def columns(self) -> list[ColumnMetadata]:
"""
List of column metadata objects.

View File

@@ -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
@@ -268,6 +269,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_api(ReportExecutionLogRestApi)
appbuilder.add_api(RLSRestApi)
appbuilder.add_api(SavedQueryRestApi)
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
from superset.semantic_layers.api import (
SemanticLayerRestApi,
SemanticViewRestApi,
)
appbuilder.add_api(SemanticLayerRestApi)
appbuilder.add_api(SemanticViewRestApi)
appbuilder.add_api(TagRestApi)
appbuilder.add_api(SqlLabRestApi)
appbuilder.add_api(SqlLabPermalinkRestApi)
@@ -300,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"),

View File

@@ -0,0 +1,166 @@
# 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.
"""add_semantic_layers_and_views
Revision ID: 33d7e0e21daa
Revises: a1b2c3d4e5f6
Create Date: 2025-11-04 11:26:00.000000
"""
import uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy_utils import UUIDType
from sqlalchemy_utils.types.json import JSONType
from superset.extensions import encrypted_field_factory
from superset.migrations.shared.utils import (
create_fks_for_table,
create_table,
drop_table,
)
# revision identifiers, used by Alembic.
revision = "33d7e0e21daa"
down_revision = "a1b2c3d4e5f6"
def upgrade() -> None:
# Create semantic_layers table
create_table(
"semantic_layers",
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False),
# created_on and changed_on are nullable=True to match AuditMixinNullable
sa.Column("created_on", sa.DateTime(), nullable=False),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("type", sa.String(length=250), nullable=False),
sa.Column(
"configuration",
encrypted_field_factory.create(JSONType),
nullable=True,
),
# configuration_version tracks the schema version of the configuration
# JSON field to aid with migrations as the schema evolves over time.
sa.Column(
"configuration_version",
sa.Integer(),
nullable=False,
server_default="1",
),
sa.Column("cache_timeout", sa.Integer(), nullable=True),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("uuid"),
)
# Create foreign key constraints for semantic_layers
create_fks_for_table(
"fk_semantic_layers_created_by_fk_ab_user",
"semantic_layers",
"ab_user",
["created_by_fk"],
["id"],
)
create_fks_for_table(
"fk_semantic_layers_changed_by_fk_ab_user",
"semantic_layers",
"ab_user",
["changed_by_fk"],
["id"],
)
# Create semantic_views table.
# The integer `id` is the primary key (auto-increment across all supported
# databases) and `uuid` is a secondary unique identifier. This follows the
# standard Superset model pattern and avoids using sa.Identity(), which is
# not supported in MySQL or SQLite.
create_table(
"semantic_views",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False),
# created_on and changed_on are nullable=True to match AuditMixinNullable
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"configuration",
encrypted_field_factory.create(JSONType),
nullable=True,
),
# configuration_version tracks the schema version of the configuration
# JSON field to aid with migrations as the schema evolves over time.
sa.Column(
"configuration_version",
sa.Integer(),
nullable=False,
server_default="1",
),
sa.Column("cache_timeout", sa.Integer(), nullable=True),
sa.Column(
"semantic_layer_uuid",
UUIDType(binary=True),
sa.ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_by_fk", sa.Integer(), nullable=True),
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("uuid"),
)
# Create foreign key constraints for semantic_views
create_fks_for_table(
"fk_semantic_views_created_by_fk_ab_user",
"semantic_views",
"ab_user",
["created_by_fk"],
["id"],
)
create_fks_for_table(
"fk_semantic_views_changed_by_fk_ab_user",
"semantic_views",
"ab_user",
["changed_by_fk"],
["id"],
)
# Update chart datasource constraint to allow semantic_view
with op.batch_alter_table("slices") as batch_op:
batch_op.drop_constraint("ck_chart_datasource", type_="check")
batch_op.create_check_constraint(
"ck_chart_datasource",
"datasource_type in ('table', 'semantic_view')",
)
def downgrade() -> None:
# Restore original constraint
with op.batch_alter_table("slices") as batch_op:
batch_op.drop_constraint("ck_chart_datasource", type_="check")
batch_op.create_check_constraint(
"ck_chart_datasource", "datasource_type in ('table')"
)
drop_table("semantic_views")
drop_table("semantic_layers")

View File

@@ -22,7 +22,7 @@ import logging
import re
from collections.abc import Hashable
from datetime import datetime
from typing import Any, Optional, TYPE_CHECKING
from typing import Any, cast, Optional, TYPE_CHECKING
import sqlalchemy as sqla
from flask import current_app as app
@@ -67,7 +67,7 @@ from superset.sql.parse import (
Table,
)
from superset.sqllab.limiting_factor import LimitingFactor
from superset.superset_typing import ExplorableData, QueryObjectDict
from superset.superset_typing import DatasetColumnData, ExplorableData, QueryObjectDict
from superset.utils import json
from superset.utils.core import (
get_column_name,
@@ -261,7 +261,7 @@ class Query(
],
"filter_select": True,
"name": self.tab_name,
"columns": [o.data for o in self.columns],
"columns": [cast(DatasetColumnData, o.data) for o in self.columns],
"metrics": [],
"id": self.id,
"type": self.type,

View File

@@ -0,0 +1,16 @@
# 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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,912 @@
# 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.
"""
Functions for mapping `QueryObject` to semantic layers.
These functions validate and convert a `QueryObject` into one or more `SemanticQuery`,
which are then passed to semantic layer implementations for execution, returning a
single dataframe.
"""
from datetime import datetime, timedelta
from time import time
from typing import Any, cast, Sequence, TypeGuard
import isodate
import numpy as np
import pyarrow as pa
from superset_core.semantic_layers.types import (
AdhocExpression,
Dimension,
Filter,
FilterValues,
Grain,
Grains,
GroupLimit,
Metric,
Operator,
OrderDirection,
OrderTuple,
PredicateType,
SemanticQuery,
SemanticResult,
)
from superset_core.semantic_layers.view import SemanticViewFeature
from superset.common.db_query_status import QueryStatus
from superset.common.query_object import QueryObject
from superset.common.utils.time_range_utils import get_since_until_from_query_object
from superset.connectors.sqla.models import BaseDatasource
from superset.constants import NO_TIME_RANGE
from superset.models.helpers import QueryResult
from superset.superset_typing import AdhocColumn
from superset.utils.core import (
FilterOperator,
QueryObjectFilterClause,
TIME_COMPARISON,
)
from superset.utils.date_parser import get_past_or_future
class ValidatedQueryObjectFilterClause(QueryObjectFilterClause):
"""
A validated QueryObject filter clause with a string column name.
The `col` in a `QueryObjectFilterClause` can be either a string (column name) or an
adhoc column, but we only support the former in semantic layers.
"""
# overwrite to narrow type; mypy complains about more restrictive typed dicts,
# but the alternative would be to redefine the object
col: str # type: ignore[misc]
op: str # type: ignore[misc]
class ValidatedQueryObject(QueryObject):
"""
A query object that has a datasource defined.
"""
datasource: BaseDatasource
# overwrite to narrow type; mypy complains about the assignment since the base type
# allows adhoc filters, but we only support validated filters here
filter: list[ValidatedQueryObjectFilterClause] # type: ignore[assignment]
series_columns: Sequence[str] # type: ignore[assignment]
series_limit_metric: str | None
def get_results(query_object: QueryObject) -> QueryResult:
"""
Run 1+ queries based on `QueryObject` and return the results.
:param query_object: The QueryObject containing query specifications
:return: QueryResult compatible with Superset's query interface
"""
if not validate_query_object(query_object):
raise ValueError("QueryObject must have a datasource defined.")
# Track execution time
start_time = time()
semantic_view = query_object.datasource.implementation
dispatcher = (
semantic_view.get_row_count
if query_object.is_rowcount
else semantic_view.get_table
)
# Step 1: Convert QueryObject to list of SemanticQuery objects
# The first query is the main query, subsequent queries are for time offsets
queries = map_query_object(query_object)
# Step 2: Execute the main query (first in the list)
main_query = queries[0]
main_result = dispatcher(main_query)
main_df = main_result.results.to_pandas()
# Collect all requests (SQL queries, HTTP requests, etc.) for troubleshooting
all_requests = list(main_result.requests)
# If no time offsets, return the main result as-is
if not query_object.time_offsets or len(queries) <= 1:
duration = timedelta(seconds=time() - start_time)
return map_semantic_result_to_query_result(
main_result,
query_object,
duration,
)
# Get metric names from the main query
# These are the columns that will be renamed with offset suffixes
metric_names = [metric.name for metric in main_query.metrics]
# Join keys are all columns except metrics
# These will be used to match rows between main and offset DataFrames
join_keys = [col for col in main_df.columns if col not in metric_names]
# Step 3 & 4: Execute each time offset query and join results
for offset_query, time_offset in zip(
queries[1:],
query_object.time_offsets,
strict=False,
):
# Execute the offset query
result = dispatcher(offset_query)
# Add this query's requests to the collection
all_requests.extend(result.requests)
offset_df = result.results.to_pandas()
# Handle empty results - add NaN columns directly instead of merging
# This avoids dtype mismatch issues with empty DataFrames
if offset_df.empty:
# Add offset metric columns with NaN values directly to main_df
for metric in metric_names:
offset_col_name = TIME_COMPARISON.join([metric, time_offset])
main_df[offset_col_name] = np.nan
else:
# Rename metric columns with time offset suffix
# Format: "{metric_name}__{time_offset}"
# Example: "revenue" -> "revenue__1 week ago"
offset_df = offset_df.rename(
columns={
metric: TIME_COMPARISON.join([metric, time_offset])
for metric in metric_names
}
)
# Step 5: Perform left join on dimension columns
# This preserves all rows from main_df and adds offset metrics
# where they match
main_df = main_df.merge(
offset_df,
on=join_keys,
how="left",
suffixes=("", "__duplicate"),
)
# Clean up any duplicate columns that might have been created
# (shouldn't happen with proper join keys, but defensive programming)
duplicate_cols = [
col for col in main_df.columns if col.endswith("__duplicate")
]
if duplicate_cols:
main_df = main_df.drop(columns=duplicate_cols)
# Convert final result to QueryResult
semantic_result = SemanticResult(
requests=all_requests,
results=pa.Table.from_pandas(main_df),
)
duration = timedelta(seconds=time() - start_time)
return map_semantic_result_to_query_result(
semantic_result,
query_object,
duration,
)
def map_semantic_result_to_query_result(
semantic_result: SemanticResult,
query_object: ValidatedQueryObject,
duration: timedelta,
) -> QueryResult:
"""
Convert a SemanticResult to a QueryResult.
:param semantic_result: Result from the semantic layer
:param query_object: Original QueryObject (for passthrough attributes)
:param duration: Time taken to execute the query
:return: QueryResult compatible with Superset's query interface
"""
# Get the query string from requests (typically one or more SQL queries)
query_str = ""
if semantic_result.requests:
# Join all requests for display (could be multiple for time comparisons)
query_str = "\n\n".join(
f"-- {req.type}\n{req.definition}" for req in semantic_result.requests
)
return QueryResult(
# Core data
df=semantic_result.results.to_pandas(),
query=query_str,
duration=duration,
# Template filters - not applicable to semantic layers
# (semantic layers don't use Jinja templates)
applied_template_filters=None,
# Filter columns - not applicable to semantic layers
# (semantic layers handle filter validation internally)
applied_filter_columns=None,
rejected_filter_columns=None,
# Status - always success if we got here
# (errors would raise exceptions before reaching this point)
status=QueryStatus.SUCCESS,
error_message=None,
errors=None,
# Time range - pass through from original query_object
from_dttm=query_object.from_dttm,
to_dttm=query_object.to_dttm,
)
def _normalize_column(column: str | AdhocColumn, dimension_names: set[str]) -> str:
"""
Normalize a column to its dimension name.
Columns can be either:
- A string (dimension name directly)
- An AdhocColumn with isColumnReference=True and sqlExpression containing the
dimension name
"""
if isinstance(column, str):
return column
# Handle column references (e.g., from time-series charts)
if column.get("isColumnReference") and (sql_expr := column.get("sqlExpression")):
if sql_expr in dimension_names:
return sql_expr
raise ValueError("Adhoc dimensions are not supported in Semantic Views.")
def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
"""
Convert a `QueryObject` into a list of `SemanticQuery`.
This function maps the `QueryObject` into query objects that focus less on
visualization and more on semantics.
"""
semantic_view = query_object.datasource.implementation
all_metrics = {metric.name: metric for metric in semantic_view.metrics}
all_dimensions = {
dimension.name: dimension for dimension in semantic_view.dimensions
}
# Normalize columns (may be dicts with isColumnReference=True for time-series)
dimension_names = set(all_dimensions.keys())
normalized_columns = {
_normalize_column(column, dimension_names) for column in query_object.columns
}
metrics = [all_metrics[metric] for metric in (query_object.metrics or [])]
grain = _convert_time_grain(query_object.extras.get("time_grain_sqla"))
dimensions = [
dimension
for dimension in semantic_view.dimensions
if dimension.name in normalized_columns
and (
# if a grain is specified, only include the time dimension if its grain
# matches the requested grain
grain is None
or dimension.name != query_object.granularity
or dimension.grain == grain
)
]
order = _get_order_from_query_object(query_object, all_metrics, all_dimensions)
limit = query_object.row_limit
offset = query_object.row_offset
group_limit = _get_group_limit_from_query_object(
query_object,
all_metrics,
all_dimensions,
)
queries = []
for time_offset in [None] + query_object.time_offsets:
filters = _get_filters_from_query_object(
query_object,
time_offset,
all_dimensions,
)
queries.append(
SemanticQuery(
metrics=metrics,
dimensions=dimensions,
filters=filters,
order=order,
limit=limit,
offset=offset,
group_limit=group_limit,
)
)
return queries
def _get_filters_from_query_object(
query_object: ValidatedQueryObject,
time_offset: str | None,
all_dimensions: dict[str, Dimension],
) -> set[Filter]:
"""
Extract all filters from the query object, including time range filters.
This simplifies the complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm
by converting all time constraints into filters.
"""
filters: set[Filter] = set()
# 1. Add fetch values predicate if present
if (
query_object.apply_fetch_values_predicate
and query_object.datasource.fetch_values_predicate
):
filters.add(
Filter(
type=PredicateType.WHERE,
column=None,
operator=Operator.ADHOC,
value=query_object.datasource.fetch_values_predicate,
)
)
# 2. Add time range filter based on from_dttm/to_dttm
# For time offsets, this automatically calculates the shifted bounds
time_filters = _get_time_filter(query_object, time_offset, all_dimensions)
filters.update(time_filters)
# 3. Add filters from query_object.extras (WHERE and HAVING clauses)
extras_filters = _get_filters_from_extras(query_object.extras)
filters.update(extras_filters)
# 4. Add all other filters from query_object.filter
for filter_ in query_object.filter:
# Skip temporal range filters - we're using inner bounds instead
if (
filter_.get("op") == FilterOperator.TEMPORAL_RANGE.value
and query_object.granularity
):
continue
if converted_filters := _convert_query_object_filter(filter_, all_dimensions):
filters.update(converted_filters)
return filters
def _get_filters_from_extras(extras: dict[str, Any]) -> set[Filter]:
"""
Extract filters from the extras dict.
The extras dict can contain various keys that affect query behavior:
Supported keys (converted to filters):
- "where": SQL WHERE clause expression (e.g., "customer_id > 100")
- "having": SQL HAVING clause expression (e.g., "SUM(sales) > 1000")
Other keys in extras (handled elsewhere in the mapper):
- "time_grain_sqla": Time granularity (e.g., "P1D", "PT1H")
Handled in _convert_time_grain() and used for dimension grain matching
Note: The WHERE and HAVING clauses from extras are SQL expressions that
are passed through as-is to the semantic layer as adhoc Filter objects.
"""
filters: set[Filter] = set()
# Add WHERE clause from extras
if where_clause := extras.get("where"):
filters.add(
Filter(
type=PredicateType.WHERE,
column=None,
operator=Operator.ADHOC,
value=where_clause,
)
)
# Add HAVING clause from extras
if having_clause := extras.get("having"):
filters.add(
Filter(
type=PredicateType.HAVING,
column=None,
operator=Operator.ADHOC,
value=having_clause,
)
)
return filters
def _get_time_filter(
query_object: ValidatedQueryObject,
time_offset: str | None,
all_dimensions: dict[str, Dimension],
) -> set[Filter]:
"""
Create a time range filter from the query object.
This handles both regular queries and time offset queries, simplifying the
complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm by using the
same time bounds for both the main query and series limit subqueries.
"""
filters: set[Filter] = set()
if not query_object.granularity:
return filters
time_dimension = all_dimensions.get(query_object.granularity)
if not time_dimension:
return filters
# Get the appropriate time bounds based on whether this is a time offset query
from_dttm, to_dttm = _get_time_bounds(query_object, time_offset)
if not from_dttm or not to_dttm:
return filters
# Create a filter with >= and < operators
return {
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.GREATER_THAN_OR_EQUAL,
value=from_dttm,
),
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.LESS_THAN,
value=to_dttm,
),
}
def _get_time_bounds(
query_object: ValidatedQueryObject,
time_offset: str | None,
) -> tuple[datetime | None, datetime | None]:
"""
Get the appropriate time bounds for the query.
For regular queries (time_offset is None), returns from_dttm/to_dttm.
For time offset queries, calculates the shifted bounds.
This simplifies the inner_from_dttm/inner_to_dttm complexity by using
the same bounds for both main queries and series limit subqueries (Option 1).
"""
if time_offset is None:
# Main query: use from_dttm/to_dttm directly
return query_object.from_dttm, query_object.to_dttm
# Time offset query: calculate shifted bounds
# Use from_dttm/to_dttm if available, otherwise try to get from time_range
outer_from = query_object.from_dttm
outer_to = query_object.to_dttm
if not outer_from or not outer_to:
# Fall back to parsing time_range if from_dttm/to_dttm not set
outer_from, outer_to = get_since_until_from_query_object(query_object)
if not outer_from or not outer_to:
return None, None
# Apply the offset to both bounds
offset_from = get_past_or_future(time_offset, outer_from)
offset_to = get_past_or_future(time_offset, outer_to)
return offset_from, offset_to
def _convert_query_object_filter(
filter_: ValidatedQueryObjectFilterClause,
all_dimensions: dict[str, Dimension],
) -> set[Filter] | None:
"""
Convert a QueryObject filter dict to a semantic layer Filter.
"""
operator_str = filter_["op"]
# Handle simple column filters
col = filter_.get("col")
if col not in all_dimensions:
return None
dimension = all_dimensions[col]
val_str = filter_["val"]
value: FilterValues | frozenset[FilterValues]
if val_str is None:
value = None
elif isinstance(val_str, (list, tuple)):
value = frozenset(val_str)
else:
value = val_str
# Special case for temporal range
if operator_str == FilterOperator.TEMPORAL_RANGE.value:
if not isinstance(value, str) or value == NO_TIME_RANGE:
return None
start, end = value.split(" : ")
return {
Filter(
type=PredicateType.WHERE,
column=dimension,
operator=Operator.GREATER_THAN_OR_EQUAL,
value=start,
),
Filter(
type=PredicateType.WHERE,
column=dimension,
operator=Operator.LESS_THAN,
value=end,
),
}
# Map QueryObject operators to semantic layer operators
operator_mapping = {
FilterOperator.EQUALS.value: Operator.EQUALS,
FilterOperator.NOT_EQUALS.value: Operator.NOT_EQUALS,
FilterOperator.GREATER_THAN.value: Operator.GREATER_THAN,
FilterOperator.LESS_THAN.value: Operator.LESS_THAN,
FilterOperator.GREATER_THAN_OR_EQUALS.value: Operator.GREATER_THAN_OR_EQUAL,
FilterOperator.LESS_THAN_OR_EQUALS.value: Operator.LESS_THAN_OR_EQUAL,
FilterOperator.IN.value: Operator.IN,
FilterOperator.NOT_IN.value: Operator.NOT_IN,
FilterOperator.LIKE.value: Operator.LIKE,
FilterOperator.NOT_LIKE.value: Operator.NOT_LIKE,
FilterOperator.IS_NULL.value: Operator.IS_NULL,
FilterOperator.IS_NOT_NULL.value: Operator.IS_NOT_NULL,
}
operator = operator_mapping.get(operator_str)
if not operator:
# Unknown operator - raise error to prevent unauthorized access
raise ValueError(f"Unsupported filter operator: {operator_str}")
return {
Filter(
type=PredicateType.WHERE,
column=dimension,
operator=operator,
value=value,
)
}
def _get_order_from_query_object(
query_object: ValidatedQueryObject,
all_metrics: dict[str, Metric],
all_dimensions: dict[str, Dimension],
) -> list[OrderTuple]:
order: list[OrderTuple] = []
for element, ascending in query_object.orderby:
direction = OrderDirection.ASC if ascending else OrderDirection.DESC
# adhoc
if isinstance(element, dict):
if element["sqlExpression"] is not None:
order.append(
(
AdhocExpression(
id=element["label"] or element["sqlExpression"],
definition=element["sqlExpression"],
),
direction,
)
)
elif element in all_dimensions:
order.append((all_dimensions[element], direction))
elif element in all_metrics:
order.append((all_metrics[element], direction))
return order
def _get_group_limit_from_query_object(
query_object: ValidatedQueryObject,
all_metrics: dict[str, Metric],
all_dimensions: dict[str, Dimension],
) -> GroupLimit | None:
# no limit
if query_object.series_limit == 0 or not query_object.columns:
return None
dimensions = [all_dimensions[dim_id] for dim_id in query_object.series_columns]
top = query_object.series_limit
metric = (
all_metrics[query_object.series_limit_metric]
if query_object.series_limit_metric
else None
)
direction = OrderDirection.DESC if query_object.order_desc else OrderDirection.ASC
group_others = query_object.group_others_when_limit_reached
# Check if we need separate filters for the group limit subquery
# This happens when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm
group_limit_filters = _get_group_limit_filters(query_object, all_dimensions)
return GroupLimit(
dimensions=dimensions,
top=top,
metric=metric,
direction=direction,
group_others=group_others,
filters=group_limit_filters,
)
def _get_group_limit_filters(
query_object: ValidatedQueryObject,
all_dimensions: dict[str, Dimension],
) -> set[Filter] | None:
"""
Get separate filters for the group limit subquery if needed.
This is used when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm,
which happens during time comparison queries. The group limit subquery may need
different time bounds to determine the top N groups.
Returns None if the group limit should use the same filters as the main query.
"""
# Check if inner time bounds are explicitly set and differ from outer bounds
if (
query_object.inner_from_dttm is None
or query_object.inner_to_dttm is None
or (
query_object.inner_from_dttm == query_object.from_dttm
and query_object.inner_to_dttm == query_object.to_dttm
)
):
# No separate bounds needed - use the same filters as the main query
return None
# Create separate filters for the group limit subquery
filters: set[Filter] = set()
# Add time range filter using inner bounds
if query_object.granularity:
time_dimension = all_dimensions.get(query_object.granularity)
if (
time_dimension
and query_object.inner_from_dttm
and query_object.inner_to_dttm
):
filters.update(
{
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.GREATER_THAN_OR_EQUAL,
value=query_object.inner_from_dttm,
),
Filter(
type=PredicateType.WHERE,
column=time_dimension,
operator=Operator.LESS_THAN,
value=query_object.inner_to_dttm,
),
}
)
# Add fetch values predicate if present
if (
query_object.apply_fetch_values_predicate
and query_object.datasource.fetch_values_predicate
):
filters.add(
Filter(
type=PredicateType.WHERE,
column=None,
operator=Operator.ADHOC,
value=query_object.datasource.fetch_values_predicate,
)
)
# Add filters from query_object.extras (WHERE and HAVING clauses)
extras_filters = _get_filters_from_extras(query_object.extras)
filters.update(extras_filters)
# Add all other non-temporal filters from query_object.filter
for filter_ in query_object.filter:
# Skip temporal range filters - we're using inner bounds instead
if (
filter_.get("op") == FilterOperator.TEMPORAL_RANGE.value
and query_object.granularity
):
continue
if converted_filters := _convert_query_object_filter(filter_, all_dimensions):
filters.update(converted_filters)
return filters if filters else 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 (TypeError, ValueError, isodate.ISO8601Error):
return None
def validate_query_object(
query_object: QueryObject,
) -> TypeGuard[ValidatedQueryObject]:
"""
Validate that the `QueryObject` is compatible with the `SemanticView`.
If some semantic view implementation supports these features we should add an
attribute to the `SemanticViewImplementation` to indicate support for them.
"""
if not query_object.datasource:
return False
query_object = cast(ValidatedQueryObject, query_object)
_validate_metrics(query_object)
_validate_dimensions(query_object)
_validate_filters(query_object)
_validate_granularity(query_object)
_validate_group_limit(query_object)
_validate_orderby(query_object)
return True
def _validate_metrics(query_object: ValidatedQueryObject) -> None:
"""
Make sure metrics are defined in the semantic view.
"""
semantic_view = query_object.datasource.implementation
if any(not isinstance(metric, str) for metric in (query_object.metrics or [])):
raise ValueError("Adhoc metrics are not supported in Semantic Views.")
metric_names = {metric.name for metric in semantic_view.metrics}
if not set(query_object.metrics or []) <= metric_names:
raise ValueError("All metrics must be defined in the Semantic View.")
def _validate_dimensions(query_object: ValidatedQueryObject) -> None:
"""
Make sure all dimensions are defined in the semantic view.
"""
semantic_view = query_object.datasource.implementation
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
# Normalize all columns to dimension names
normalized_columns = [
_normalize_column(column, dimension_names) for column in query_object.columns
]
if not set(normalized_columns) <= dimension_names:
raise ValueError("All dimensions must be defined in the Semantic View.")
def _validate_filters(query_object: ValidatedQueryObject) -> None:
"""
Make sure all filters are valid.
"""
for filter_ in query_object.filter:
if isinstance(filter_["col"], dict):
raise ValueError(
"Adhoc columns are not supported in Semantic View filters."
)
if not filter_.get("op"):
raise ValueError("All filters must have an operator defined.")
def _validate_granularity(query_object: ValidatedQueryObject) -> None:
"""
Make sure time column and time grain are valid.
"""
semantic_view = query_object.datasource.implementation
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
if time_column := query_object.granularity:
if time_column not in dimension_names:
raise ValueError(
"The time column must be defined in the Semantic View dimensions."
)
if time_grain := query_object.extras.get("time_grain_sqla"):
if not time_column:
raise ValueError(
"A time column must be specified when a time grain is provided."
)
supported_time_grains = {
dimension.grain
for dimension in semantic_view.dimensions
if dimension.name == time_column and dimension.grain
}
if _convert_time_grain(time_grain) not in supported_time_grains:
raise ValueError(
"The time grain is not supported for the time column in the "
"Semantic View."
)
def _validate_group_limit(query_object: ValidatedQueryObject) -> None:
"""
Validate group limit related features in the query object.
"""
semantic_view = query_object.datasource.implementation
# no limit
if query_object.series_limit == 0:
return
if (
query_object.series_columns
and SemanticViewFeature.GROUP_LIMIT not in semantic_view.features
):
raise ValueError("Group limit is not supported in this Semantic View.")
if any(not isinstance(col, str) for col in query_object.series_columns):
raise ValueError("Adhoc dimensions are not supported in series columns.")
metric_names = {metric.name for metric in semantic_view.metrics}
if query_object.series_limit_metric and (
not isinstance(query_object.series_limit_metric, str)
or query_object.series_limit_metric not in metric_names
):
raise ValueError(
"The series limit metric must be defined in the Semantic View."
)
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
if not set(query_object.series_columns) <= dimension_names:
raise ValueError("All series columns must be defined in the Semantic View.")
if (
query_object.group_others_when_limit_reached
and SemanticViewFeature.GROUP_OTHERS not in semantic_view.features
):
raise ValueError(
"Grouping others when limit is reached is not supported in this Semantic "
"View."
)
def _validate_orderby(query_object: ValidatedQueryObject) -> None:
"""
Validate order by elements in the query object.
"""
semantic_view = query_object.datasource.implementation
if (
any(not isinstance(element, str) for element, _ in query_object.orderby)
and SemanticViewFeature.ADHOC_EXPRESSIONS_IN_ORDERBY
not in semantic_view.features
):
raise ValueError(
"Adhoc expressions in order by are not supported in this Semantic View."
)
elements = {orderby[0] for orderby in query_object.orderby}
metric_names = {metric.name for metric in semantic_view.metrics}
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
if not elements <= metric_names | dimension_names:
raise ValueError("All order by elements must be defined in the Semantic View.")

View File

@@ -0,0 +1,409 @@
# 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.
"""Semantic layer models."""
from __future__ import annotations
import uuid
from collections.abc import Hashable
from dataclasses import dataclass
from functools import cached_property
from typing import Any, TYPE_CHECKING
import pyarrow as pa
from flask_appbuilder import Model
from sqlalchemy import Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from sqlalchemy_utils.types.json import JSONType
from superset_core.semantic_layers.layer import (
SemanticLayer as SemanticLayerABC,
)
from superset_core.semantic_layers.view import (
SemanticView as SemanticViewABC,
)
from superset.common.query_object import QueryObject
from superset.explorables.base import TimeGrainDict
from superset.extensions import encrypted_field_factory
from superset.models.helpers import AuditMixinNullable, QueryResult
from superset.semantic_layers.mapper import get_results
from superset.semantic_layers.registry import registry
from superset.utils import json
from superset.utils.core import GenericDataType
if TYPE_CHECKING:
from superset.superset_typing import ExplorableData, QueryObjectDict
def get_column_type(semantic_type: pa.DataType) -> GenericDataType:
"""
Map Arrow data types to generic data types.
"""
if pa.types.is_date(semantic_type) or pa.types.is_timestamp(semantic_type):
return GenericDataType.TEMPORAL
if pa.types.is_time(semantic_type):
return GenericDataType.TEMPORAL
if (
pa.types.is_integer(semantic_type)
or pa.types.is_floating(semantic_type)
or pa.types.is_decimal(semantic_type)
or pa.types.is_duration(semantic_type)
):
return GenericDataType.NUMERIC
if pa.types.is_boolean(semantic_type):
return GenericDataType.BOOLEAN
return GenericDataType.STRING
@dataclass(frozen=True)
class MetricMetadata:
metric_name: str
expression: str
verbose_name: str | None = None
description: str | None = None
d3format: str | None = None
currency: dict[str, Any] | None = None
warning_text: str | None = None
certified_by: str | None = None
certification_details: str | None = None
@dataclass(frozen=True)
class ColumnMetadata:
column_name: str
type: str
is_dttm: bool
verbose_name: str | None = None
description: str | None = None
groupby: bool = True
filterable: bool = True
expression: str | None = None
python_date_format: str | None = None
advanced_data_type: str | None = None
extra: str | None = None
class SemanticLayer(AuditMixinNullable, Model):
"""
Semantic layer model.
A semantic layer provides an abstraction over data sources,
allowing users to query data through a semantic interface.
"""
__tablename__ = "semantic_layers"
uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4)
# Core fields
name = Column(String(250), nullable=False)
description = Column(Text, nullable=True)
type = Column(String(250), nullable=False) # snowflake, etc
configuration = Column(encrypted_field_factory.create(JSONType), default="{}")
# Tracks the schema version of the configuration JSON field to aid with
# migrations as the configuration schema evolves over time.
configuration_version = Column(Integer, nullable=False, default=1)
cache_timeout = Column(Integer, nullable=True)
# Semantic views relationship
semantic_views: list[SemanticView] = relationship(
"SemanticView",
back_populates="semantic_layer",
cascade="all, delete-orphan",
passive_deletes=True,
)
def __repr__(self) -> str:
return self.name or str(self.uuid)
@cached_property
def implementation(
self,
) -> SemanticLayerABC[Any, SemanticViewABC]:
"""
Return semantic layer implementation.
"""
# TODO (betodealmeida):
# return extension_manager.get_contribution("semanticLayers", self.type)
class_ = registry[self.type]
return class_.from_configuration(json.loads(self.configuration))
class SemanticView(AuditMixinNullable, Model):
"""
Semantic view model.
A semantic view represents a queryable view within a semantic layer.
"""
__tablename__ = "semantic_views"
# Use integer as the primary key for cross-database auto-increment
# compatibility (sa.Identity() is not supported in MySQL or SQLite).
# The uuid column is a secondary unique identifier used in URLs and perms.
id = Column(Integer, primary_key=True, autoincrement=True)
uuid = Column(UUIDType(binary=True), unique=True, default=uuid.uuid4)
# Core fields
name = Column(String(250), nullable=False)
description = Column(Text, nullable=True)
configuration = Column(encrypted_field_factory.create(JSONType), default="{}")
# Tracks the schema version of the configuration JSON field to aid with
# migrations as the configuration schema evolves over time.
configuration_version = Column(Integer, nullable=False, default=1)
cache_timeout = Column(Integer, nullable=True)
# Semantic layer relationship
semantic_layer_uuid = Column(
UUIDType(binary=True),
ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
nullable=False,
)
semantic_layer: SemanticLayer = relationship(
"SemanticLayer",
back_populates="semantic_views",
foreign_keys=[semantic_layer_uuid],
)
def __repr__(self) -> str:
return self.name or str(self.uuid)
@cached_property
def implementation(self) -> SemanticViewABC:
"""
Return semantic view implementation.
"""
return self.semantic_layer.implementation.get_semantic_view(
self.name,
json.loads(self.configuration),
)
# =========================================================================
# Explorable protocol implementation
# =========================================================================
def get_query_result(self, query_object: QueryObject) -> QueryResult:
return get_results(query_object)
def get_query_str(self, query_obj: QueryObjectDict) -> str:
return "Not implemented for semantic layers"
@property
def table_name(self) -> str:
return self.name
@property
def kind(self) -> str:
return "semantic_view"
@property
def uid(self) -> str:
return self.implementation.uid()
@property
def type(self) -> str:
return "semantic_view"
@property
def metrics(self) -> list[MetricMetadata]:
return [
MetricMetadata(
metric_name=metric.name,
expression=metric.definition,
description=metric.description,
)
for metric in self.implementation.get_metrics()
]
@property
def columns(self) -> list[ColumnMetadata]:
return [
ColumnMetadata(
column_name=dimension.name,
type=str(dimension.type),
is_dttm=pa.types.is_date(dimension.type)
or pa.types.is_time(dimension.type)
or pa.types.is_timestamp(dimension.type),
description=dimension.description,
expression=dimension.definition,
extra=json.dumps(
{"grain": dimension.grain.name if dimension.grain else None}
),
)
for dimension in self.implementation.get_dimensions()
]
@property
def column_names(self) -> list[str]:
return [dimension.name for dimension in self.implementation.get_dimensions()]
@property
def data(self) -> ExplorableData:
return {
# core
"id": self.id,
"uid": self.uid,
"type": "semantic_view",
"name": self.name,
"columns": [
{
"advanced_data_type": None,
"certification_details": None,
"certified_by": None,
"column_name": dimension.name,
"description": dimension.description,
"expression": dimension.definition,
"filterable": True,
"groupby": True,
"id": None,
"uuid": None,
"is_certified": False,
"is_dttm": pa.types.is_date(dimension.type)
or pa.types.is_time(dimension.type)
or pa.types.is_timestamp(dimension.type),
"python_date_format": None,
"type": str(dimension.type),
"type_generic": get_column_type(dimension.type),
"verbose_name": None,
"warning_markdown": None,
}
for dimension in self.implementation.get_dimensions()
],
"metrics": [
{
"certification_details": None,
"certified_by": None,
"d3format": None,
"description": metric.description,
"expression": metric.definition,
"id": None,
"uuid": None,
"is_certified": False,
"metric_name": metric.name,
"warning_markdown": None,
"warning_text": None,
"verbose_name": None,
}
for metric in self.implementation.get_metrics()
],
"database": {},
"parent": {"name": self.semantic_layer.name},
# UI features
"verbose_map": {},
"order_by_choices": [],
"filter_select": True,
"filter_select_enabled": True,
"sql": None,
"select_star": None,
"owners": [],
"description": self.description,
"table_name": self.name,
"column_types": [
get_column_type(dimension.type)
for dimension in self.implementation.get_dimensions()
],
"column_names": [
dimension.name for dimension in self.implementation.get_dimensions()
],
# rare
"column_formats": {},
"datasource_name": self.name,
"perm": self.perm,
"offset": self.offset,
"cache_timeout": self.cache_timeout,
"params": None,
# sql-specific
"schema": None,
"catalog": None,
"main_dttm_col": None,
"time_grain_sqla": [],
"granularity_sqla": [],
"fetch_values_predicate": None,
"template_params": None,
"is_sqllab_view": False,
"extra": None,
"always_filter_main_dttm": False,
"normalize_columns": False,
"edit_url": "",
"default_endpoint": None,
"folders": [],
"health_check_message": None,
}
def data_for_slices(self, slices: list[Any]) -> ExplorableData:
return self.data
def get_extra_cache_keys(self, query_obj: QueryObjectDict) -> list[Hashable]:
return []
@property
def perm(self) -> str:
return self.semantic_layer_uuid.hex + "::" + self.uuid.hex
@property
def catalog_perm(self) -> str | None:
return None
@property
def schema_perm(self) -> str | None:
return None
@property
def schema(self) -> str | None:
return None
@property
def url(self) -> str:
return f"/semantic_view/{self.uuid}/"
@property
def explore_url(self) -> str:
return f"/explore/?datasource_type=semantic_view&datasource_id={self.id}"
@property
def offset(self) -> int:
# always return datetime as UTC
return 0
def get_time_grains(self) -> list[TimeGrainDict]:
return [
{
"name": dimension.grain.name,
"function": "",
"duration": dimension.grain.representation,
}
for dimension in self.implementation.get_dimensions()
if dimension.grain
]
def has_drill_by_columns(self, column_names: list[str]) -> bool:
dimension_names = {
dimension.name for dimension in self.implementation.get_dimensions()
}
return all(column_name in dimension_names for column_name in column_names)
@property
def is_rls_supported(self) -> bool:
return False
@property
def query_language(self) -> str | None:
return None

View File

@@ -0,0 +1,24 @@
# 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 superset_core.semantic_layers.layer import SemanticLayer
registry: dict[str, type[SemanticLayer[Any, Any]]] = {}

View File

@@ -0,0 +1,45 @@
# 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 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)
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)

View File

@@ -30,6 +30,46 @@ if TYPE_CHECKING:
SQLType: TypeAlias = TypeEngine | type[TypeEngine]
class DatasetColumnData(TypedDict, total=False):
"""Type for column metadata in ExplorableData datasets."""
advanced_data_type: str | None
certification_details: str | None
certified_by: str | None
column_name: str
description: str | None
expression: str | None
filterable: bool
groupby: bool
id: int | None
uuid: str | None
is_certified: bool
is_dttm: bool
python_date_format: str | None
type: str
type_generic: NotRequired["GenericDataType" | None]
verbose_name: str | None
warning_markdown: str | None
class DatasetMetricData(TypedDict, total=False):
"""Type for metric metadata in ExplorableData datasets."""
certification_details: str | None
certified_by: str | None
currency: NotRequired[dict[str, Any]]
d3format: str | None
description: str | None
expression: str | None
id: int | None
uuid: str | None
is_certified: bool
metric_name: str
warning_markdown: str | None
warning_text: str | None
verbose_name: str | None
class LegacyMetric(TypedDict):
label: str | None
@@ -254,11 +294,12 @@ class ExplorableData(TypedDict, total=False):
"""
# Core fields from BaseDatasource.data
id: int
id: int | str # String for UUID-based explorables like SemanticView
uid: str
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
@@ -274,8 +315,8 @@ class ExplorableData(TypedDict, total=False):
perm: str | None
edit_url: str
sql: str | None
columns: list[dict[str, Any]]
metrics: list[dict[str, Any]]
columns: list["DatasetColumnData"]
metrics: list["DatasetMetricData"]
folders: Any # JSON field, can be list or dict
order_by_choices: list[tuple[str, str]]
owners: list[int] | list[dict[str, Any]] # Can be either format
@@ -283,8 +324,8 @@ class ExplorableData(TypedDict, total=False):
select_star: str | None
# Additional fields from SqlaTable and data_for_slices
column_types: list[Any]
column_names: set[str] | set[Any]
column_types: list["GenericDataType"]
column_names: set[str] | list[str]
granularity_sqla: list[tuple[Any, Any]]
time_grain_sqla: list[tuple[Any, Any]]
main_dttm_col: str | None

View File

@@ -96,7 +96,6 @@ from superset.exceptions import (
SupersetException,
SupersetTimeoutException,
)
from superset.explorables.base import Explorable
from superset.sql.parse import sanitize_clause
from superset.superset_typing import (
AdhocColumn,
@@ -115,7 +114,7 @@ from superset.utils.hashing import hash_from_dict, hash_from_str
from superset.utils.pandas import detect_datetime_format
if TYPE_CHECKING:
from superset.connectors.sqla.models import TableColumn
from superset.explorables.base import ColumnMetadata, Explorable
from superset.models.core import Database
logging.getLogger("MARKDOWN").setLevel(logging.INFO)
@@ -200,6 +199,7 @@ class DatasourceType(StrEnum):
QUERY = "query"
SAVEDQUERY = "saved_query"
VIEW = "view"
SEMANTIC_VIEW = "semantic_view"
class LoggerLevel(StrEnum):
@@ -1730,15 +1730,12 @@ def get_metric_type_from_column(column: Any, datasource: Explorable) -> str:
:return: The inferred metric type as a string, or an empty string if the
column is not a metric or no valid operation is found.
"""
from superset.connectors.sqla.models import SqlMetric
metric: SqlMetric = next(
(metric for metric in datasource.metrics if metric.metric_name == column),
SqlMetric(metric_name=""),
metric = next(
(m for m in datasource.metrics if m.metric_name == column),
None,
)
if metric.metric_name == "":
if metric is None:
return ""
expression: str = metric.expression
@@ -1784,7 +1781,7 @@ def extract_dataframe_dtypes(
generic_types: list[GenericDataType] = []
for column in df.columns:
column_object = columns_by_name.get(column)
column_object = columns_by_name.get(str(column))
series = df[column]
inferred_type: str = ""
if series.isna().all():
@@ -1814,11 +1811,17 @@ def extract_dataframe_dtypes(
return generic_types
def extract_column_dtype(col: TableColumn) -> GenericDataType:
if col.is_temporal:
def extract_column_dtype(col: ColumnMetadata) -> GenericDataType:
# Check for temporal type
if hasattr(col, "is_temporal") and col.is_temporal:
return GenericDataType.TEMPORAL
if col.is_numeric:
if col.is_dttm:
return GenericDataType.TEMPORAL
# Check for numeric type
if hasattr(col, "is_numeric") and col.is_numeric:
return GenericDataType.NUMERIC
# TODO: add check for boolean data type when proper support is added
return GenericDataType.STRING
@@ -1832,9 +1835,7 @@ def get_time_filter_status(
applied_time_extras: dict[str, str],
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
temporal_columns: set[Any] = {
(col.column_name if hasattr(col, "column_name") else col.get("column_name"))
for col in datasource.columns
if (col.is_dttm if hasattr(col, "is_dttm") else col.get("is_dttm"))
col.column_name for col in datasource.columns if col.is_dttm
}
applied: list[dict[str, str]] = []
rejected: list[dict[str, str]] = []

View File

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

View File

@@ -626,7 +626,8 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
assert response == {
"message": {
"datasource_type": [
"Must be one of: table, dataset, query, saved_query, view."
"Must be one of: table, dataset, query, saved_query, view, "
"semantic_view."
]
}
}
@@ -981,7 +982,8 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
assert response == {
"message": {
"datasource_type": [
"Must be one of: table, dataset, query, saved_query, view."
"Must be one of: table, dataset, query, saved_query, view, "
"semantic_view."
]
}
}

View File

@@ -204,3 +204,55 @@ class TestDatasourceApi(SupersetTestCase):
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
assert response["result"] == []
@patch("superset.datasource.api.security_manager.can_access")
@patch("superset.datasource.api.GetCombinedDatasourceListCommand.run")
def test_combined_list_invalid_order_column(
self,
run_mock,
can_access_mock,
):
security_manager.add_permission_view_menu("can_combined_list", "Datasource")
perm = security_manager.find_permission_view_menu(
"can_combined_list", "Datasource"
)
admin_role = security_manager.find_role("Admin")
security_manager.add_permission_role(admin_role, perm)
can_access_mock.side_effect = [True, True]
run_mock.side_effect = ValueError("Invalid order column: invalid")
self.login(ADMIN_USERNAME)
rv = self.client.get(
"api/v1/datasource/?q=(order_column:invalid,order_direction:desc,page:0,page_size:25)"
)
assert rv.status_code == 400
response = json.loads(rv.data.decode("utf-8"))
assert response["message"] == "Invalid order column: invalid"
@patch("superset.datasource.api.security_manager.can_access")
@patch("superset.datasource.api.GetCombinedDatasourceListCommand.run")
def test_combined_list_semantic_layers_off(
self,
run_mock,
can_access_mock,
):
security_manager.add_permission_view_menu("can_combined_list", "Datasource")
perm = security_manager.find_permission_view_menu(
"can_combined_list", "Datasource"
)
admin_role = security_manager.find_role("Admin")
security_manager.add_permission_role(admin_role, perm)
can_access_mock.return_value = True
run_mock.return_value = {"count": 1, "result": []}
self.login(ADMIN_USERNAME)
with patch("superset.datasource.api.is_feature_enabled", return_value=False):
rv = self.client.get(
"api/v1/datasource/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"
)
assert rv.status_code == 200
run_mock.assert_called_once()
_, kwargs = run_mock.call_args
assert kwargs == {}

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,141 @@
# 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 patch
import pytest
from sqlalchemy import literal, select
from superset.commands.datasource.list import GetCombinedDatasourceListCommand
def test_parse_filters_semantic_view_requires_dataset_operator() -> None:
source_type, name_filter, sql_filter, type_filter = (
GetCombinedDatasourceListCommand._parse_filters(
[{"col": "sql", "opr": "eq", "value": "semantic_view"}]
)
)
assert source_type == "all"
assert name_filter is None
assert sql_filter is None
assert type_filter is None
def test_parse_filters_semantic_view_with_dataset_operator() -> None:
source_type, name_filter, sql_filter, type_filter = (
GetCombinedDatasourceListCommand._parse_filters(
[
{
"col": "sql",
"opr": "dataset_is_null_or_empty",
"value": "semantic_view",
}
]
)
)
assert source_type == "all"
assert name_filter is None
assert sql_filter is None
assert type_filter == "semantic_view"
def test_parse_filters_sql_bool_requires_dataset_operator() -> None:
source_type, name_filter, sql_filter, type_filter = (
GetCombinedDatasourceListCommand._parse_filters(
[{"col": "sql", "opr": "eq", "value": True}]
)
)
assert source_type == "all"
assert name_filter is None
assert sql_filter is None
assert type_filter is None
def test_resolve_source_type_semantic_view_filter_forces_semantic_layer() -> None:
command = GetCombinedDatasourceListCommand(
args={},
can_read_datasets=True,
can_read_semantic_views=True,
)
source_type = command._resolve_source_type(
source_type="all",
sql_filter=None,
type_filter="semantic_view",
)
assert source_type == "semantic_layer"
def test_resolve_source_type_sql_filter_forces_database() -> None:
command = GetCombinedDatasourceListCommand(
args={},
can_read_datasets=True,
can_read_semantic_views=True,
)
source_type = command._resolve_source_type(
source_type="all",
sql_filter=True,
type_filter=None,
)
assert source_type == "database"
@pytest.mark.parametrize(
"order_column",
["unknown", "database.database_name", "id"],
)
def test_run_raises_for_invalid_sort_column(order_column: str) -> None:
command = GetCombinedDatasourceListCommand(
args={"order_column": order_column, "order_direction": "desc"},
can_read_datasets=True,
can_read_semantic_views=True,
)
ds_q = select(
literal(1).label("item_id"),
literal("database").label("source_type"),
literal("2026-01-01").label("changed_on"),
literal("name").label("table_name"),
)
sv_q = select(
literal(2).label("item_id"),
literal("semantic_layer").label("source_type"),
literal("2026-01-01").label("changed_on"),
literal("name").label("table_name"),
)
with (
patch(
"superset.commands.datasource.list.DatasourceDAO.build_dataset_query",
return_value=ds_q,
),
patch(
"superset.commands.datasource.list.DatasourceDAO.build_semantic_view_query",
return_value=sv_q,
),
patch(
"superset.commands.datasource.list.DatasourceDAO.paginate_combined_query",
side_effect=ValueError(f"Invalid order column: {order_column}"),
),
):
with pytest.raises(ValueError, match=f"Invalid order column: {order_column}"):
command.run()

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -0,0 +1,230 @@
# 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
expected = {**data, "configuration": '{"account": "test"}'}
dao.create.assert_called_once_with(attributes=expected)
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"},
}
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()

View File

@@ -0,0 +1,164 @@
# 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()
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
# 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()
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_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(
"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()
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
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()
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_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(
"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()

View File

@@ -0,0 +1,91 @@
# 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 superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
SemanticViewForbiddenError,
SemanticViewInvalidError,
SemanticViewNotFoundError,
SemanticViewUpdateFailedError,
)
def test_semantic_view_not_found_error() -> None:
"""Test SemanticViewNotFoundError has correct status and message."""
error = SemanticViewNotFoundError()
assert error.status == 404
assert str(error.message) == "Semantic view does not exist"
def test_semantic_view_forbidden_error() -> None:
"""Test SemanticViewForbiddenError has correct message."""
error = SemanticViewForbiddenError()
assert str(error.message) == "Changing this semantic view is forbidden"
def test_semantic_view_invalid_error() -> None:
"""Test SemanticViewInvalidError has correct message."""
error = SemanticViewInvalidError()
assert str(error.message) == "Semantic view parameters are invalid."
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."

View File

@@ -0,0 +1,326 @@
# 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.exceptions import (
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticViewForbiddenError,
SemanticViewNotFoundError,
)
from superset.commands.semantic_layer.update import (
UpdateSemanticLayerCommand,
UpdateSemanticViewCommand,
)
from superset.exceptions import SupersetSecurityException
def test_update_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful update of a semantic view."""
mock_model = MagicMock()
mock_model.id = 1
mock_model.configuration = "{}"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
data = {"description": "Updated", "cache_timeout": 300}
result = UpdateSemanticViewCommand(1, data).run()
assert result == mock_model
dao.find_by_id.assert_called_once_with(1)
dao.update.assert_called_once_with(mock_model, attributes=data)
def test_update_semantic_view_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticViewNotFoundError is raised when model is missing."""
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = None
with pytest.raises(SemanticViewNotFoundError):
UpdateSemanticViewCommand(999, {"description": "test"}).run()
def test_update_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised on ownership failure."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
sm = mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
# Use a regular MagicMock for raise_for_ownership to avoid AsyncMock issues
sm.raise_for_ownership = MagicMock(
side_effect=SupersetSecurityException(MagicMock()),
)
with pytest.raises(SemanticViewForbiddenError):
UpdateSemanticViewCommand(1, {"description": "test"}).run()
def test_update_semantic_view_copies_data(mocker: MockerFixture) -> None:
"""Test that the command copies input data and does not mutate it."""
mock_model = MagicMock()
mock_model.configuration = "{}"
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
dao.update.return_value = mock_model
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
original_data = {"description": "Original"}
UpdateSemanticViewCommand(1, original_data).run()
# The original dict should not have been modified
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",
layer_uuid: str = "layer-uuid-1",
configuration: str = '{"schema": "prod"}',
) -> MagicMock:
model = MagicMock()
model.uuid = uuid
model.name = name
model.semantic_layer_uuid = layer_uuid
model.configuration = configuration
return model
def test_update_uniqueness_different_config_same_name(
mocker: MockerFixture,
) -> None:
"""Same name but different configuration is allowed."""
mock_model = _make_view_model(configuration='{"schema": "prod"}')
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
dao.update.return_value = mock_model
dao.validate_update_uniqueness.return_value = True
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
# Update to a config that differs from an existing view
data = {"name": "my_view", "configuration": {"schema": "testing"}}
result = UpdateSemanticViewCommand(1, data).run()
assert result == mock_model
dao.validate_update_uniqueness.assert_called_once_with(
view_uuid="view-uuid-1",
name="my_view",
layer_uuid="layer-uuid-1",
configuration={"schema": "testing"},
)
def test_update_uniqueness_same_config_different_name(
mocker: MockerFixture,
) -> None:
"""Same configuration but different name is allowed."""
mock_model = _make_view_model(configuration='{"schema": "prod"}')
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
dao.update.return_value = mock_model
dao.validate_update_uniqueness.return_value = True
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
data = {"name": "renamed_view", "configuration": {"schema": "prod"}}
result = UpdateSemanticViewCommand(1, data).run()
assert result == mock_model
dao.validate_update_uniqueness.assert_called_once_with(
view_uuid="view-uuid-1",
name="renamed_view",
layer_uuid="layer-uuid-1",
configuration={"schema": "prod"},
)
def test_update_uniqueness_same_config_same_name_fails(
mocker: MockerFixture,
) -> None:
"""Same name and same configuration is a duplicate."""
mock_model = _make_view_model(configuration='{"schema": "prod"}')
dao = mocker.patch(
"superset.commands.semantic_layer.update.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
dao.validate_update_uniqueness.return_value = False
mocker.patch(
"superset.commands.semantic_layer.update.security_manager",
)
from superset.commands.semantic_layer.exceptions import (
SemanticViewUpdateFailedError,
)
data = {"name": "my_view", "configuration": {"schema": "prod"}}
with pytest.raises(SemanticViewUpdateFailedError):
UpdateSemanticViewCommand(1, data).run()
dao.validate_update_uniqueness.assert_called_once_with(
view_uuid="view-uuid-1",
name="my_view",
layer_uuid="layer-uuid-1",
configuration={"schema": "prod"},
)

View File

@@ -18,6 +18,7 @@
from collections.abc import Iterator
import pytest
from sqlalchemy import literal, select
from sqlalchemy.orm.session import Session
from superset.utils.core import DatasourceType
@@ -138,3 +139,31 @@ def test_not_found_datasource(session_with_data: Session) -> None:
datasource_type="table",
database_id_or_uuid=500000,
)
def test_escape_ilike_fragment() -> None:
from superset.daos.datasource import _escape_ilike_fragment
assert _escape_ilike_fragment("foo%bar_baz\\") == "foo\\%bar\\_baz\\\\"
def test_paginate_combined_query_invalid_sort_column() -> None:
from superset.daos.datasource import DatasourceDAO
combined = (
select(
literal(1).label("item_id"),
literal("database").label("source_type"),
literal("2026-01-01").label("changed_on"),
literal("name").label("table_name"),
)
).subquery()
with pytest.raises(ValueError, match="Invalid order column: invalid"):
DatasourceDAO.paginate_combined_query(
combined=combined,
order_column="invalid",
order_direction="desc",
page=0,
page_size=25,
)

Some files were not shown because too many files have changed in this diff Show More