You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by "betodealmeida (via GitHub)" <gi...@apache.org> on 2025/11/24 21:22:48 UTC

[PR] feat: Explorable protocol [superset]

betodealmeida opened a new pull request, #36245:
URL: https://github.com/apache/superset/pull/36245

   <!---
   Please write the PR title following the conventions at https://www.conventionalcommits.org/en/v1.0.0/
   Example:
   fix(dashboard): load charts correctly
   -->
   
   ### SUMMARY
   <!--- Describe the change below, including rationale and design decisions -->
   
   This PR introduces a clean, type-safe protocol for data sources in Superset that can be "explored" (visualized). While the immediate implementation focuses on SQL datasources (dataserts and queries), the protocol lays the groundwork for first-class [semantic layer support](https://github.com/apache/superset/issues/35003).
   
   #### 🎯 Core Protocol (`superset/explorables/base.py`)
   
   Introduced the `Explorable` Protocol defining the interface for any data source that can be visualized in Superset:
   
   ```python
   @runtime_checkable
   class Explorable(Protocol):
       def get_query_result(self, query_object: QueryObject) -> QueryResult: ...
       def get_query_str(self, query_obj: QueryObjectDict) -> str: ...
       def has_drill_by_columns(self, column_names: list[str]) -> bool: ...
   
       @property
       def uid(self) -> str: ...
       @property
       def type(self) -> str: ...
       @property
       def columns(self) -> list[Any]: ...
       @property
       def cache_timeout(self) -> int | None: ...
       @property
       def is_rls_supported(self) -> bool: ...
       # ... other properties
   ```
   
   **Key design decisions:**
   - **Minimal interface**: Only includes methods/properties needed for exploration
   - **No SQL-specific concepts**: `database`, `schema`, etc. are not in the protocol
   - **Runtime checkable**: Enables protocol checks without explicit inheritance
   - **Required `is_rls_supported`**: Makes RLS support explicit (can return `False`)i
   
   #### 🔧 QueryContext Integration
   
   Updated `QueryContext` to work with `Explorable` instead of just `BaseDatasource`:
   
   ```python
   # Before
   datasource: BaseDatasource
   
   # After
   datasource: Explorable
   ```
   
   This enables polymorphic handling of different data source types while maintaining full backward compatibility.
   
   #### 📊 Drill-By Abstraction
   
   **Before**: Drill-by logic was SQL-specific and lived in the security manager
   ```python
   # Security manager had SQL queries like:
   drillable_columns = {
       row[0] for row in session.query(TableColumn.column_name)
       .filter(TableColumn.table_id == datasource_id)
       .filter(TableColumn.groupby).all()
   }
   ```
   
   **After**: Drill-by is datasource-driven through the protocol
   ```python
   # Security manager (clean and datasource-agnostic)
   def has_drill_by_access(...):
       # ...
       and datasource.has_drill_by_columns(dimensions)
   
   # BaseDatasource implements the SQL-specific logic
   def has_drill_by_columns(self, column_names: list[str]) -> bool:
       drillable_columns = {
           row[0] for row in db.session.query(TableColumn.column_name)
           .filter(TableColumn.table_id == self.id)
           .filter(TableColumn.groupby).all()
       }
       return set(column_names).issubset(drillable_columns)
   ```
   
   ### Benefits (even without semantic layers)
   
   #### ✅ Immediate Value
   
   1. **Clearer boundaries**: SQL-specific code is now clearly scoped and doesn't leak into generic components
   2. **Better maintainability**: Security manager is simpler and focuses on authorization logic
   4. **Documentation**: The protocol serves as living documentation of what it means to be "explorable"
   5. **Testability**: Easier to mock and test with a defined protocol interface
   
   #### ✅ Architecture Improvements
   
   1. **Single responsibility**: Each component does one thing well
   2. **Dependency inversion**: High-level modules (security, query context) depend on abstractions
   3. **Open/closed principle**: New data source types (like semantic views) can be added without modifying existing code
   
   ### Future Semantic Layer Support
   
   When semantic layers are implemented, they'll simply implement the `Explorable` protocol:
   
   ```python
   class SemanticView:
       @property
       def uid(self) -> str:
           return f"semantic_view_{self.id}"
   
       def has_drill_by_columns(self, column_names: list[str]) -> bool:
           # Check against semantic layer dimension definitions
           return all(col in self.dimensions for col in column_names)
   
       @property
       def is_rls_supported(self) -> bool:
           return True  # Semantic layer handles RLS internally
   
       # ... implement other protocol methods
   ```
   
   **Zero changes needed** to:
   - QueryContext
   - Security manager
   - Chart data API
   - Any other component that works with `Explorable`
   
   ### Testing
   
   All existing tests pass with the new protocol. The test suite validates:
   - SQL datasource functionality remains unchanged
   - Type checking passes (`mypy`)
   - Security manager correctly handles both SQL and future non-SQL datasources
   - Drill-by logic works through the new abstraction
   
   ### Backward Compatibility
   
   This is a 100% backward-compatible refactor:
   - All existing SQL datasources work exactly as before
   - No API changes for charts, dashboards, or SQL Lab
   - No database migrations needed
   - No configuration changes required
   
   The protocol is purely additive - it formalizes what was already there, making it explicit and extensible.
   
   ### ADDITIONAL INFORMATION
   <!--- Check any relevant boxes with "x" -->
   <!--- HINT: Include "Fixes #nnn" if you are fixing an existing issue -->
   - [ ] Has associated issue:
   - [ ] Required feature flags:
   - [ ] Changes UI
   - [ ] Includes DB Migration (follow approval process in [SIP-59](https://github.com/apache/superset/issues/13351))
     - [ ] Migration is atomic, supports rollback & is backwards-compatible
     - [ ] Confirm DB migration upgrade and downgrade tested
     - [ ] Runtime estimates and downtime expectations provided
   - [ ] Introduces new feature or API
   - [ ] Removes existing feature or API
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566727014


##########
superset/commands/dataset/export.py:
##########
@@ -78,6 +78,16 @@ def _file_content(model: SqlaTable) -> str:
         payload["version"] = EXPORT_VERSION
         payload["database_uuid"] = str(model.database.uuid)
 
+        # Always set cache_timeout from the property to ensure correct value
+        payload["cache_timeout"] = model.cache_timeout
+
+        # SQLAlchemy returns column names as quoted_name objects which PyYAML cannot
+        # serialize. Convert all keys to regular strings to fix YAML serialization.
+        try:
+            payload = {str(key): value for key, value in payload.items()}
+        except ImportError:

Review Comment:
   this loop throws ImportError? 



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566720558


##########
.pylintrc:
##########
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
 
 [MESSAGES CONTROL]
 disable=all
-enable=disallowed-json-import,disallowed-sql-import,consider-using-transaction
+enable=json-import,disallowed-sql-import,consider-using-transaction

Review Comment:
   unrelated?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566723120


##########
superset/security/manager.py:
##########
@@ -746,8 +764,18 @@ def get_datasource_access_error_object(  # pylint: disable=invalid-name
             level=ErrorLevel.WARNING,
             extra={
                 "link": self.get_datasource_access_link(datasource),
-                "datasource": datasource.id,
-                "datasource_name": datasource.name,
+                "datasource": getattr(

Review Comment:
   Is it worth adding an id property to the Explorable protocol, or creating a helper method `get_explorable_id(datasource)` to DRY this up?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566725282


##########
superset/security/manager.py:
##########
@@ -2280,8 +2308,8 @@ def raise_for_access(  # noqa: C901
         dashboard: Optional["Dashboard"] = None,
         chart: Optional["Slice"] = None,
         database: Optional["Database"] = None,
-        datasource: Optional["BaseDatasource"] = None,
-        query: Optional["Query"] = None,
+        datasource: Optional["BaseDatasource | Explorable"] = None,
+        query: Optional["Query | Explorable"] = None,

Review Comment:
   could this be confusing and weakens type safety?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566820668


##########
superset/security/manager.py:
##########
@@ -2280,8 +2308,8 @@ def raise_for_access(  # noqa: C901
         dashboard: Optional["Dashboard"] = None,
         chart: Optional["Slice"] = None,
         database: Optional["Database"] = None,
-        datasource: Optional["BaseDatasource"] = None,
-        query: Optional["Query"] = None,
+        datasource: Optional["BaseDatasource | Explorable"] = None,
+        query: Optional["Query | Explorable"] = None,

Review Comment:
   Yeah, eventually I want to consolidate this into:
   
   ```suggestion
           datasource: Explorable | None = None,  # no query, just datasource
   ```
   
   But that requires some modifications to the current code, and I'm trying to just define the protocol with what we have in place first.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566730356


##########
superset/explorables/base.py:
##########
@@ -0,0 +1,354 @@
+# 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.
+"""
+Base protocol for explorable data sources in Superset.
+
+An "explorable" is any data source that can be explored to create charts,
+including SQL datasets, saved queries, and semantic layer views.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Hashable
+from datetime import datetime
+from typing import Any, Protocol, runtime_checkable
+
+from superset.common.query_object import QueryObject
+from superset.models.helpers import QueryResult
+from superset.superset_typing import QueryObjectDict
+
+
+@runtime_checkable
+class Explorable(Protocol):
+    """
+    Protocol for objects that can be explored to create charts.
+
+    This protocol defines the minimal interface required for a data source
+    to be visualizable in Superset. It is implemented by:
+    - BaseDatasource (SQL datasets and queries)
+    - SemanticView (semantic layer views)
+    - Future: Other data source types
+
+    The protocol focuses on the essential methods and properties needed
+    for query execution, caching, and security.
+    """
+
+    # =========================================================================
+    # Core Query Interface
+    # =========================================================================
+
+    def get_query_result(self, query_object: QueryObject) -> QueryResult:
+        """
+        Execute a query and return results.
+
+        This is the primary method for data retrieval. It takes a query
+        object describing what data to fetch (columns, metrics, filters, time range,
+        etc.) and returns a QueryResult containing a pandas DataFrame with the results.
+
+        :param query_obj: QueryObject describing the query
+
+        :return: QueryResult containing:
+            - df: pandas DataFrame with query results
+            - query: string representation of the executed query
+            - duration: query execution time
+            - status: QueryStatus (SUCCESS/FAILED)
+            - error_message: error details if query failed
+        """
+
+    def get_query_str(self, query_obj: QueryObjectDict) -> str:
+        """
+        Get the query string without executing.
+
+        Returns a string representation of the query that would be executed
+        for the given query object. This is used for display in the UI
+        and debugging.
+
+        :param query_obj: Dictionary describing the query
+        :return: String representation of the query (SQL, GraphQL, etc.)
+        """
+
+    # =========================================================================
+    # Identity & Metadata
+    # =========================================================================
+
+    @property
+    def uid(self) -> str:
+        """
+        Unique identifier for this explorable.
+
+        Used as part of cache keys and for tracking. Should be stable
+        across application restarts but change when the explorable's
+        data or structure changes.
+
+        Format convention: "{type}_{id}" (e.g., "table_123", "semantic_view_abc")
+
+        :return: Unique identifier string
+        """
+
+    @property
+    def type(self) -> str:
+        """
+        Type discriminator for this explorable.
+
+        Identifies the kind of data source (e.g., 'table', 'query', 'semantic_view').
+        Used for routing and type-specific behavior.
+
+        :return: Type identifier string
+        """
+
+    @property
+    def metrics(self) -> list[Any]:
+        """
+        List of metric metadata objects.
+
+        Each object should provide at minimum:
+        - metric_name: str - the metric's name
+        - expression: str - the metric's calculation expression
+
+        Used for validation, autocomplete, and query building.
+
+        :return: List of metric metadata objects
+        """
+
+    # TODO: rename to dimensions
+    @property
+    def columns(self) -> list[Any]:
+        """
+        List of column metadata objects.
+
+        Each object should provide at minimum:
+        - column_name: str - the column's name
+        - type: str - the column's data type
+        - is_dttm: bool - whether it's a datetime column
+
+        Used for validation, autocomplete, and query building.
+
+        :return: List of column metadata objects
+        """
+
+    # TODO: remove and use columns instead
+    @property
+    def column_names(self) -> list[str]:
+        """
+        List of available column names.
+
+        A simple list of all column names in the explorable.
+        Used for quick validation and filtering.
+
+        :return: List of column name strings
+        """
+
+    # TODO: use TypedDict for return type
+    @property
+    def data(self) -> dict[str, Any]:
+        """
+        Full metadata representation sent to the frontend.
+
+        This property returns a dictionary containing all the metadata
+        needed by the Explore UI, including columns, metrics, and
+        other configuration.
+
+        Required keys in the returned dictionary:
+        - id: unique identifier (int or str)
+        - uid: unique string identifier
+        - name: display name
+        - type: explorable type ('table', 'query', 'semantic_view', etc.)
+        - columns: list of column metadata dicts (with column_name, type, etc.)
+        - metrics: list of metric metadata dicts (with metric_name, expression, etc.)
+        - database: database metadata dict (with id, backend, etc.)
+
+        Optional keys:
+        - description: human-readable description
+        - schema: schema name (if applicable)
+        - catalog: catalog name (if applicable)
+        - cache_timeout: default cache timeout
+        - offset: timezone offset
+        - owners: list of owner IDs
+        - verbose_map: dict mapping column/metric names to display names
+
+        :return: Dictionary with complete explorable metadata
+        """
+
+    # =========================================================================
+    # Caching
+    # =========================================================================
+
+    @property
+    def cache_timeout(self) -> int | None:
+        """
+        Default cache timeout in seconds.
+
+        Determines how long query results should be cached.
+        Returns None to use the system default cache timeout.
+
+        :return: Cache timeout in seconds, or None for system default
+        """
+
+    @property
+    def changed_on(self) -> datetime | None:
+        """
+        Last modification timestamp.
+
+        Used for cache invalidation - when this changes, cached
+        results for this explorable become invalid.
+
+        :return: Datetime of last modification, or None
+        """
+
+    def get_extra_cache_keys(self, query_obj: QueryObjectDict) -> list[Hashable]:
+        """
+        Additional cache key components specific to this explorable.
+
+        Provides explorable-specific values to include in cache keys.
+        Used to ensure cache invalidation when the explorable's
+        underlying data or configuration changes in ways not captured
+        by uid or changed_on.
+
+        :param query_obj: The query being executed
+        :return: List of additional hashable values for cache key
+        """
+
+    # =========================================================================
+    # Security
+    # =========================================================================
+
+    @property
+    def perm(self) -> str:
+        """
+        Permission string for this explorable.
+
+        Used by the security manager to check if a user has access
+        to this data source. Format depends on the explorable type
+        (e.g., "[database].[schema].[table]" for SQL tables).
+
+        :return: Permission identifier string
+        """
+
+    # =========================================================================
+    # Time/Date Handling
+    # =========================================================================
+
+    @property
+    def offset(self) -> int:
+        """
+        Timezone offset for datetime columns.
+
+        Used to normalize datetime values to the user's timezone.
+        Returns 0 for UTC, or an offset in seconds.
+
+        :return: Timezone offset in seconds (0 for UTC)
+        """
+
+    # =========================================================================
+    # Time Granularity
+    # =========================================================================
+
+    def get_time_grains(self) -> list[dict[str, Any]]:
+        """
+        Get available time granularities for temporal grouping.
+
+        Returns a list of time grain options that can be used for grouping
+        temporal data. Each time grain specifies how to bucket timestamps
+        (e.g., by hour, day, week, month).
+
+        Each dictionary in the returned list should contain:
+        - name: str - Display name (e.g., "Hour", "Day", "Week")
+        - function: str - How to apply the grain (implementation-specific)
+        - duration: str - ISO 8601 duration string (e.g., "PT1H", "P1D", "P1W")
+
+        For SQL datasources, the function is typically a SQL expression template
+        like "DATE_TRUNC('hour', {col})". For semantic layers, it might be a
+        semantic layer-specific identifier like "hour" or "day".
+
+        Return an empty list if time grains are not supported or applicable.
+
+        Example return value:
+        ```python
+        [
+            {
+                "name": "Second",
+                "function": "DATE_TRUNC('second', {col})",
+                "duration": "PT1S",
+            },
+            {
+                "name": "Minute",
+                "function": "DATE_TRUNC('minute', {col})",
+                "duration": "PT1M",
+            },
+            {
+                "name": "Hour",
+                "function": "DATE_TRUNC('hour', {col})",
+                "duration": "PT1H",
+            },
+            {
+                "name": "Day",
+                "function": "DATE_TRUNC('day', {col})",
+                "duration": "P1D",
+            },
+        ]
+        ```
+
+        :return: List of time grain dictionaries (empty list if not supported)
+        """
+
+    # =========================================================================
+    # Drilling
+    # =========================================================================
+
+    def has_drill_by_columns(self, column_names: list[str]) -> bool:
+        """
+        Check if the specified columns support drill-by operations.
+
+        Drill-by allows users to navigate from aggregated views to detailed
+        data by grouping on specific dimensions. This method determines whether
+        the given columns can be used for drill-by in the current datasource.
+
+        For SQL datasources, this typically checks if columns are marked as
+        groupable in the metadata. For semantic views, it checks against the
+        semantic layer's dimension definitions.
+
+        :param column_names: List of column names to check
+        :return: True if all columns support drill-by, False otherwise
+        """
+
+    # =========================================================================
+    # Optional Properties
+    # =========================================================================
+
+    @property
+    def is_rls_supported(self) -> bool:
+        """
+        Whether this explorable supports Row Level Security.
+
+        Row Level Security (RLS) allows filtering data based on user identity.
+        SQL-based datasources typically support this via SQL queries, while
+        semantic layers may handle security at the semantic layer level.
+
+        :return: True if RLS is supported, False otherwise
+        """
+        return False

Review Comment:
   i think this just gets ignored since its a protocol? 



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566820668


##########
superset/security/manager.py:
##########
@@ -2280,8 +2308,8 @@ def raise_for_access(  # noqa: C901
         dashboard: Optional["Dashboard"] = None,
         chart: Optional["Slice"] = None,
         database: Optional["Database"] = None,
-        datasource: Optional["BaseDatasource"] = None,
-        query: Optional["Query"] = None,
+        datasource: Optional["BaseDatasource | Explorable"] = None,
+        query: Optional["Query | Explorable"] = None,

Review Comment:
   Yeah, eventually I want to consolidate this into:
   
   ```suggestion
           datasource: Explorable | None = None,
   ```
   
   But that requires some modifications to the current code, and I'm trying to just define the protocol with what we have in place first.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566727752


##########
superset/connectors/sqla/models.py:
##########
@@ -212,6 +212,78 @@ def name(self) -> str:
 
     extra_import_fields = ["is_managed_externally", "external_url"]
 
+    @property
+    def cache_timeout(self) -> int | None:
+        """
+        Get the cache timeout for this datasource.
+
+        Implements the Explorable protocol by handling the fallback chain:
+        1. Datasource-specific timeout (if set)
+        2. Database default timeout (if no datasource timeout)
+        3. None (use system default)
+
+        This allows each datasource to override caching, while falling back
+        to database-level defaults when appropriate.
+        """
+        if self._cache_timeout is not None:
+            return self._cache_timeout
+
+        # database should always be set, but that's not true for v0 import
+        if self.database:
+            return self.database.cache_timeout
+
+        return None
+
+    @cache_timeout.setter
+    def cache_timeout(self, value: int | None) -> None:
+        """Set the datasource-specific cache timeout."""
+        self._cache_timeout = value
+
+    def has_drill_by_columns(self, column_names: list[str]) -> bool:
+        """
+        Check if the specified columns support drill-by operations.
+
+        For SQL datasources, drill-by is supported on columns that are marked
+        as groupable in the metadata. This allows users to navigate from
+        aggregated views to detailed data by grouping on these dimensions.
+
+        :param column_names: List of column names to check
+        :return: True if all columns support drill-by, False otherwise
+        """
+        if not column_names:
+            return False
+
+        # Get all groupable column names for this datasource
+        drillable_columns = {
+            row[0]
+            for row in db.session.query(TableColumn.column_name)
+            .filter(TableColumn.table_id == self.id)
+            .filter(TableColumn.groupby)
+            .all()
+        }
+
+        # Check if all requested columns are drillable
+        return set(column_names).issubset(drillable_columns)
+
+    def get_time_grains(self) -> list[dict[str, Any]]:

Review Comment:
   is TypedDict useful here? 



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2581497008


##########
superset/connectors/sqla/models.py:
##########
@@ -212,6 +212,78 @@ def name(self) -> str:
 
     extra_import_fields = ["is_managed_externally", "external_url"]
 
+    @property
+    def cache_timeout(self) -> int | None:
+        """
+        Get the cache timeout for this datasource.
+
+        Implements the Explorable protocol by handling the fallback chain:
+        1. Datasource-specific timeout (if set)
+        2. Database default timeout (if no datasource timeout)
+        3. None (use system default)
+
+        This allows each datasource to override caching, while falling back
+        to database-level defaults when appropriate.
+        """
+        if self._cache_timeout is not None:
+            return self._cache_timeout
+
+        # database should always be set, but that's not true for v0 import
+        if self.database:
+            return self.database.cache_timeout
+
+        return None
+
+    @cache_timeout.setter
+    def cache_timeout(self, value: int | None) -> None:
+        """Set the datasource-specific cache timeout."""
+        self._cache_timeout = value
+
+    def has_drill_by_columns(self, column_names: list[str]) -> bool:
+        """
+        Check if the specified columns support drill-by operations.
+
+        For SQL datasources, drill-by is supported on columns that are marked
+        as groupable in the metadata. This allows users to navigate from
+        aggregated views to detailed data by grouping on these dimensions.
+
+        :param column_names: List of column names to check
+        :return: True if all columns support drill-by, False otherwise
+        """
+        if not column_names:
+            return False
+
+        # Get all groupable column names for this datasource
+        drillable_columns = {
+            row[0]
+            for row in db.session.query(TableColumn.column_name)
+            .filter(TableColumn.table_id == self.id)
+            .filter(TableColumn.groupby)
+            .all()
+        }
+
+        # Check if all requested columns are drillable
+        return set(column_names).issubset(drillable_columns)
+
+    def get_time_grains(self) -> list[dict[str, Any]]:

Review Comment:
   Doing it here: https://github.com/apache/superset/pull/36367



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566822279


##########
superset/commands/dataset/export.py:
##########
@@ -78,6 +78,16 @@ def _file_content(model: SqlaTable) -> str:
         payload["version"] = EXPORT_VERSION
         payload["database_uuid"] = str(model.database.uuid)
 
+        # Always set cache_timeout from the property to ensure correct value
+        payload["cache_timeout"] = model.cache_timeout
+
+        # SQLAlchemy returns column names as quoted_name objects which PyYAML cannot
+        # serialize. Convert all keys to regular strings to fix YAML serialization.
+        try:
+            payload = {str(key): value for key, value in payload.items()}
+        except ImportError:

Review Comment:
   D'oh, let me fix that.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566817970


##########
superset/security/manager.py:
##########
@@ -746,8 +764,18 @@ def get_datasource_access_error_object(  # pylint: disable=invalid-name
             level=ErrorLevel.WARNING,
             extra={
                 "link": self.get_datasource_access_link(datasource),
-                "datasource": datasource.id,
-                "datasource_name": datasource.name,
+                "datasource": getattr(

Review Comment:
   Good point, let me improve this.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #36245:
URL: https://github.com/apache/superset/pull/36245#issuecomment-3572775607

   AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566824542


##########
superset/explorables/base.py:
##########
@@ -0,0 +1,354 @@
+# 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.
+"""
+Base protocol for explorable data sources in Superset.
+
+An "explorable" is any data source that can be explored to create charts,
+including SQL datasets, saved queries, and semantic layer views.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Hashable
+from datetime import datetime
+from typing import Any, Protocol, runtime_checkable
+
+from superset.common.query_object import QueryObject
+from superset.models.helpers import QueryResult
+from superset.superset_typing import QueryObjectDict
+
+
+@runtime_checkable
+class Explorable(Protocol):
+    """
+    Protocol for objects that can be explored to create charts.
+
+    This protocol defines the minimal interface required for a data source
+    to be visualizable in Superset. It is implemented by:
+    - BaseDatasource (SQL datasets and queries)
+    - SemanticView (semantic layer views)
+    - Future: Other data source types
+
+    The protocol focuses on the essential methods and properties needed
+    for query execution, caching, and security.
+    """
+
+    # =========================================================================
+    # Core Query Interface
+    # =========================================================================
+
+    def get_query_result(self, query_object: QueryObject) -> QueryResult:
+        """
+        Execute a query and return results.
+
+        This is the primary method for data retrieval. It takes a query
+        object describing what data to fetch (columns, metrics, filters, time range,
+        etc.) and returns a QueryResult containing a pandas DataFrame with the results.
+
+        :param query_obj: QueryObject describing the query
+
+        :return: QueryResult containing:
+            - df: pandas DataFrame with query results
+            - query: string representation of the executed query
+            - duration: query execution time
+            - status: QueryStatus (SUCCESS/FAILED)
+            - error_message: error details if query failed
+        """
+
+    def get_query_str(self, query_obj: QueryObjectDict) -> str:
+        """
+        Get the query string without executing.
+
+        Returns a string representation of the query that would be executed
+        for the given query object. This is used for display in the UI
+        and debugging.
+
+        :param query_obj: Dictionary describing the query
+        :return: String representation of the query (SQL, GraphQL, etc.)
+        """
+
+    # =========================================================================
+    # Identity & Metadata
+    # =========================================================================
+
+    @property
+    def uid(self) -> str:
+        """
+        Unique identifier for this explorable.
+
+        Used as part of cache keys and for tracking. Should be stable
+        across application restarts but change when the explorable's
+        data or structure changes.
+
+        Format convention: "{type}_{id}" (e.g., "table_123", "semantic_view_abc")
+
+        :return: Unique identifier string
+        """
+
+    @property
+    def type(self) -> str:
+        """
+        Type discriminator for this explorable.
+
+        Identifies the kind of data source (e.g., 'table', 'query', 'semantic_view').
+        Used for routing and type-specific behavior.
+
+        :return: Type identifier string
+        """
+
+    @property
+    def metrics(self) -> list[Any]:
+        """
+        List of metric metadata objects.
+
+        Each object should provide at minimum:
+        - metric_name: str - the metric's name
+        - expression: str - the metric's calculation expression
+
+        Used for validation, autocomplete, and query building.
+
+        :return: List of metric metadata objects
+        """
+
+    # TODO: rename to dimensions
+    @property
+    def columns(self) -> list[Any]:
+        """
+        List of column metadata objects.
+
+        Each object should provide at minimum:
+        - column_name: str - the column's name
+        - type: str - the column's data type
+        - is_dttm: bool - whether it's a datetime column
+
+        Used for validation, autocomplete, and query building.
+
+        :return: List of column metadata objects
+        """
+
+    # TODO: remove and use columns instead
+    @property
+    def column_names(self) -> list[str]:
+        """
+        List of available column names.
+
+        A simple list of all column names in the explorable.
+        Used for quick validation and filtering.
+
+        :return: List of column name strings
+        """
+
+    # TODO: use TypedDict for return type
+    @property
+    def data(self) -> dict[str, Any]:
+        """
+        Full metadata representation sent to the frontend.
+
+        This property returns a dictionary containing all the metadata
+        needed by the Explore UI, including columns, metrics, and
+        other configuration.
+
+        Required keys in the returned dictionary:
+        - id: unique identifier (int or str)
+        - uid: unique string identifier
+        - name: display name
+        - type: explorable type ('table', 'query', 'semantic_view', etc.)
+        - columns: list of column metadata dicts (with column_name, type, etc.)
+        - metrics: list of metric metadata dicts (with metric_name, expression, etc.)
+        - database: database metadata dict (with id, backend, etc.)
+
+        Optional keys:
+        - description: human-readable description
+        - schema: schema name (if applicable)
+        - catalog: catalog name (if applicable)
+        - cache_timeout: default cache timeout
+        - offset: timezone offset
+        - owners: list of owner IDs
+        - verbose_map: dict mapping column/metric names to display names
+
+        :return: Dictionary with complete explorable metadata
+        """
+
+    # =========================================================================
+    # Caching
+    # =========================================================================
+
+    @property
+    def cache_timeout(self) -> int | None:
+        """
+        Default cache timeout in seconds.
+
+        Determines how long query results should be cached.
+        Returns None to use the system default cache timeout.
+
+        :return: Cache timeout in seconds, or None for system default
+        """
+
+    @property
+    def changed_on(self) -> datetime | None:
+        """
+        Last modification timestamp.
+
+        Used for cache invalidation - when this changes, cached
+        results for this explorable become invalid.
+
+        :return: Datetime of last modification, or None
+        """
+
+    def get_extra_cache_keys(self, query_obj: QueryObjectDict) -> list[Hashable]:
+        """
+        Additional cache key components specific to this explorable.
+
+        Provides explorable-specific values to include in cache keys.
+        Used to ensure cache invalidation when the explorable's
+        underlying data or configuration changes in ways not captured
+        by uid or changed_on.
+
+        :param query_obj: The query being executed
+        :return: List of additional hashable values for cache key
+        """
+
+    # =========================================================================
+    # Security
+    # =========================================================================
+
+    @property
+    def perm(self) -> str:
+        """
+        Permission string for this explorable.
+
+        Used by the security manager to check if a user has access
+        to this data source. Format depends on the explorable type
+        (e.g., "[database].[schema].[table]" for SQL tables).
+
+        :return: Permission identifier string
+        """
+
+    # =========================================================================
+    # Time/Date Handling
+    # =========================================================================
+
+    @property
+    def offset(self) -> int:
+        """
+        Timezone offset for datetime columns.
+
+        Used to normalize datetime values to the user's timezone.
+        Returns 0 for UTC, or an offset in seconds.
+
+        :return: Timezone offset in seconds (0 for UTC)
+        """
+
+    # =========================================================================
+    # Time Granularity
+    # =========================================================================
+
+    def get_time_grains(self) -> list[dict[str, Any]]:
+        """
+        Get available time granularities for temporal grouping.
+
+        Returns a list of time grain options that can be used for grouping
+        temporal data. Each time grain specifies how to bucket timestamps
+        (e.g., by hour, day, week, month).
+
+        Each dictionary in the returned list should contain:
+        - name: str - Display name (e.g., "Hour", "Day", "Week")
+        - function: str - How to apply the grain (implementation-specific)
+        - duration: str - ISO 8601 duration string (e.g., "PT1H", "P1D", "P1W")
+
+        For SQL datasources, the function is typically a SQL expression template
+        like "DATE_TRUNC('hour', {col})". For semantic layers, it might be a
+        semantic layer-specific identifier like "hour" or "day".
+
+        Return an empty list if time grains are not supported or applicable.
+
+        Example return value:
+        ```python
+        [
+            {
+                "name": "Second",
+                "function": "DATE_TRUNC('second', {col})",
+                "duration": "PT1S",
+            },
+            {
+                "name": "Minute",
+                "function": "DATE_TRUNC('minute', {col})",
+                "duration": "PT1M",
+            },
+            {
+                "name": "Hour",
+                "function": "DATE_TRUNC('hour', {col})",
+                "duration": "PT1H",
+            },
+            {
+                "name": "Day",
+                "function": "DATE_TRUNC('day', {col})",
+                "duration": "P1D",
+            },
+        ]
+        ```
+
+        :return: List of time grain dictionaries (empty list if not supported)
+        """
+
+    # =========================================================================
+    # Drilling
+    # =========================================================================
+
+    def has_drill_by_columns(self, column_names: list[str]) -> bool:
+        """
+        Check if the specified columns support drill-by operations.
+
+        Drill-by allows users to navigate from aggregated views to detailed
+        data by grouping on specific dimensions. This method determines whether
+        the given columns can be used for drill-by in the current datasource.
+
+        For SQL datasources, this typically checks if columns are marked as
+        groupable in the metadata. For semantic views, it checks against the
+        semantic layer's dimension definitions.
+
+        :param column_names: List of column names to check
+        :return: True if all columns support drill-by, False otherwise
+        """
+
+    # =========================================================================
+    # Optional Properties
+    # =========================================================================
+
+    @property
+    def is_rls_supported(self) -> bool:
+        """
+        Whether this explorable supports Row Level Security.
+
+        Row Level Security (RLS) allows filtering data based on user identity.
+        SQL-based datasources typically support this via SQL queries, while
+        semantic layers may handle security at the semantic layer level.
+
+        :return: True if RLS is supported, False otherwise
+        """
+        return False

Review Comment:
   Yep, you're right! Let me fix it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566823932


##########
superset/connectors/sqla/models.py:
##########
@@ -212,6 +212,78 @@ def name(self) -> str:
 
     extra_import_fields = ["is_managed_externally", "external_url"]
 
+    @property
+    def cache_timeout(self) -> int | None:
+        """
+        Get the cache timeout for this datasource.
+
+        Implements the Explorable protocol by handling the fallback chain:
+        1. Datasource-specific timeout (if set)
+        2. Database default timeout (if no datasource timeout)
+        3. None (use system default)
+
+        This allows each datasource to override caching, while falling back
+        to database-level defaults when appropriate.
+        """
+        if self._cache_timeout is not None:
+            return self._cache_timeout
+
+        # database should always be set, but that's not true for v0 import
+        if self.database:
+            return self.database.cache_timeout
+
+        return None
+
+    @cache_timeout.setter
+    def cache_timeout(self, value: int | None) -> None:
+        """Set the datasource-specific cache timeout."""
+        self._cache_timeout = value
+
+    def has_drill_by_columns(self, column_names: list[str]) -> bool:
+        """
+        Check if the specified columns support drill-by operations.
+
+        For SQL datasources, drill-by is supported on columns that are marked
+        as groupable in the metadata. This allows users to navigate from
+        aggregated views to detailed data by grouping on these dimensions.
+
+        :param column_names: List of column names to check
+        :return: True if all columns support drill-by, False otherwise
+        """
+        if not column_names:
+            return False
+
+        # Get all groupable column names for this datasource
+        drillable_columns = {
+            row[0]
+            for row in db.session.query(TableColumn.column_name)
+            .filter(TableColumn.table_id == self.id)
+            .filter(TableColumn.groupby)
+            .all()
+        }
+
+        # Check if all requested columns are drillable
+        return set(column_names).issubset(drillable_columns)
+
+    def get_time_grains(self) -> list[dict[str, Any]]:

Review Comment:
   Yeah, I want to tighten up some of these types — same for `metrics` and `columns` (which I want to rename to dimensions). But that will come next, for now I'm keeping types unchanged.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida commented on code in PR #36245:
URL: https://github.com/apache/superset/pull/36245#discussion_r2566817346


##########
.pylintrc:
##########
@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
 
 [MESSAGES CONTROL]
 disable=all
-enable=disallowed-json-import,disallowed-sql-import,consider-using-transaction
+enable=json-import,disallowed-sql-import,consider-using-transaction

Review Comment:
   Yeah, this is just a drive-by fix. :)



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org


Re: [PR] feat: Explorable protocol [superset]

Posted by "betodealmeida (via GitHub)" <gi...@apache.org>.
betodealmeida merged PR #36245:
URL: https://github.com/apache/superset/pull/36245


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org