Compare commits

..

9 Commits

Author SHA1 Message Date
Beto Dealmeida
508aad1603 Enable extension in Docker 2026-02-09 14:57:40 -05:00
Beto Dealmeida
954cf32ca4 Temporary registry 2026-02-09 14:57:10 -05:00
Beto Dealmeida
552c685a6b Fix models 2026-02-09 13:55:34 -05:00
Beto Dealmeida
a26c91c4e2 Fix mapper 2026-02-09 13:54:11 -05:00
Beto Dealmeida
3c8835bd75 Fix migration 2026-02-09 10:48:05 -05:00
Beto Dealmeida
955d8bc205 Frontend support 2026-02-06 19:02:51 -05:00
Beto Dealmeida
cd8e27d33c API integration 2026-02-06 16:28:08 -05:00
Beto Dealmeida
d0962bd32f feat: models and DAOs 2026-02-06 16:27:58 -05:00
Beto Dealmeida
28870168cd feat: semantic layer extension 2026-02-06 13:42:39 -05:00
21 changed files with 156 additions and 304 deletions

View File

@@ -21,6 +21,7 @@ import enum
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from superset_core.semantic_layers.types import ( from superset_core.semantic_layers.types import (
AdhocFilter,
Dimension, Dimension,
Filter, Filter,
GroupLimit, GroupLimit,
@@ -68,7 +69,7 @@ class SemanticView(Protocol):
def get_values( def get_values(
self, self,
dimension: Dimension, dimension: Dimension,
filters: set[Filter] | None = None, filters: set[Filter | AdhocFilter] | None = None,
) -> SemanticResult: ) -> SemanticResult:
""" """
Return distinct values for a dimension. Return distinct values for a dimension.
@@ -78,7 +79,7 @@ class SemanticView(Protocol):
self, self,
metrics: list[Metric], metrics: list[Metric],
dimensions: list[Dimension], dimensions: list[Dimension],
filters: set[Filter] | None = None, filters: set[Filter | AdhocFilter] | None = None,
order: list[OrderTuple] | None = None, order: list[OrderTuple] | None = None,
limit: int | None = None, limit: int | None = None,
offset: int | None = None, offset: int | None = None,
@@ -93,7 +94,7 @@ class SemanticView(Protocol):
self, self,
metrics: list[Metric], metrics: list[Metric],
dimensions: list[Dimension], dimensions: list[Dimension],
filters: set[Filter] | None = None, filters: set[Filter | AdhocFilter] | None = None,
order: list[OrderTuple] | None = None, order: list[OrderTuple] | None = None,
limit: int | None = None, limit: int | None = None,
offset: int | None = None, offset: int | None = None,

View File

@@ -239,7 +239,6 @@ class Operator(str, enum.Enum):
NOT_LIKE = "NOT LIKE" NOT_LIKE = "NOT LIKE"
IS_NULL = "IS NULL" IS_NULL = "IS NULL"
IS_NOT_NULL = "IS NOT NULL" IS_NOT_NULL = "IS NOT NULL"
ADHOC = "ADHOC"
FilterValues = str | int | float | bool | datetime | date | time | timedelta | None FilterValues = str | int | float | bool | datetime | date | time | timedelta | None
@@ -253,11 +252,19 @@ class PredicateType(enum.Enum):
@dataclass(frozen=True, order=True) @dataclass(frozen=True, order=True)
class Filter: class Filter:
type: PredicateType type: PredicateType
column: Dimension | Metric | None column: Dimension | Metric
operator: Operator operator: Operator
value: FilterValues | frozenset[FilterValues] value: FilterValues | frozenset[FilterValues]
# TODO (betodealmeida): convert into Operator:
# Filter(type=..., column=None, operator=Operator.AdHoc, value="some definition")
@dataclass(frozen=True, order=True)
class AdhocFilter:
type: PredicateType
definition: str
class OrderDirection(enum.Enum): class OrderDirection(enum.Enum):
ASC = "ASC" ASC = "ASC"
DESC = "DESC" DESC = "DESC"
@@ -284,7 +291,7 @@ class GroupLimit:
metric: Metric | None metric: Metric | None
direction: OrderDirection = OrderDirection.DESC direction: OrderDirection = OrderDirection.DESC
group_others: bool = False group_others: bool = False
filters: set[Filter] | None = None filters: set[Filter | AdhocFilter] | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -321,7 +328,7 @@ class SemanticQuery:
metrics: list[Metric] metrics: list[Metric]
dimensions: list[Dimension] dimensions: list[Dimension]
filters: set[Filter] | None = None filters: set[Filter | AdhocFilter] | None = None
order: list[OrderTuple] | None = None order: list[OrderTuple] | None = None
limit: int | None = None limit: int | None = None
offset: int | None = None offset: int | None = None

View File

@@ -29,13 +29,14 @@ const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
}; };
export default class DatasourceKey { export default class DatasourceKey {
readonly id: number; readonly id: number | string;
readonly type: DatasourceType; readonly type: DatasourceType;
constructor(key: string) { constructor(key: string) {
const [idStr, typeStr] = key.split('__'); const [idStr, typeStr] = key.split('__');
this.id = parseInt(idStr, 10); const isNumeric = /^\d+$/.test(idStr);
this.id = isNumeric ? parseInt(idStr, 10) : idStr;
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table; this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
} }

View File

@@ -38,7 +38,7 @@ export interface Currency {
* Datasource metadata. * Datasource metadata.
*/ */
export interface Datasource { export interface Datasource {
id: number; id: number | string;
name: string; name: string;
type: DatasourceType; type: DatasourceType;
columns: Column[]; columns: Column[];

View File

@@ -159,7 +159,7 @@ export interface QueryObject
export interface QueryContext { export interface QueryContext {
datasource: { datasource: {
id: number; id: number | string;
type: DatasourceType; type: DatasourceType;
}; };
/** Force refresh of all queries */ /** Force refresh of all queries */

View File

@@ -90,11 +90,15 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
findPermission('can_explore', 'Superset', state.user?.roles), findPermission('can_explore', 'Superset', state.user?.roles),
); );
const [datasource_id, datasource_type] = formData.datasource.split('__'); const [datasourceIdStr, datasource_type] = formData.datasource.split('__');
const isNumeric = /^\d+$/.test(datasourceIdStr);
const datasource_id = isNumeric
? parseInt(datasourceIdStr, 10)
: datasourceIdStr;
useEffect(() => { useEffect(() => {
// short circuit if the user is embedded as explore is not available // short circuit if the user is embedded as explore is not available
if (isEmbedded()) return; if (isEmbedded()) return;
postFormData(Number(datasource_id), datasource_type, formData, 0) postFormData(datasource_id, datasource_type, formData, 0)
.then(key => { .then(key => {
setUrl( setUrl(
`/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`, `/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,

View File

@@ -272,7 +272,7 @@ export type Slice = {
changed_on: number; changed_on: number;
changed_on_humanized: string; changed_on_humanized: string;
modified: string; modified: string;
datasource_id: number; datasource_id: number | string;
datasource_type: DatasourceType; datasource_type: DatasourceType;
datasource_url: string; datasource_url: string;
datasource_name: string; datasource_name: string;

View File

@@ -144,15 +144,19 @@ export const getSlicePayload = async (
...adhocFilters, ...adhocFilters,
dashboards, dashboards,
}; };
let datasourceId = 0; let datasourceId: number | string = 0;
let datasourceType: DatasourceType = DatasourceType.Table; let datasourceType: DatasourceType = DatasourceType.Table;
if (formData.datasource) { if (formData.datasource) {
const [id, typeString] = formData.datasource.split('__'); const [id, typeString] = formData.datasource.split('__');
datasourceId = parseInt(id, 10); const isNumeric = /^\d+$/.test(id);
datasourceId = isNumeric ? parseInt(id, 10) : id;
if (Object.values(DatasourceType).includes(typeString as DatasourceType)) { const formattedTypeString =
datasourceType = typeString as DatasourceType; typeString.charAt(0).toUpperCase() + typeString.slice(1);
if (formattedTypeString in DatasourceType) {
datasourceType =
DatasourceType[formattedTypeString as keyof typeof DatasourceType];
} }
} }

View File

@@ -20,7 +20,7 @@ import { SupersetClient, JsonObject, JsonResponse } from '@superset-ui/core';
import { sanitizeFormData } from 'src/utils/sanitizeFormData'; import { sanitizeFormData } from 'src/utils/sanitizeFormData';
type Payload = { type Payload = {
datasource_id: number; datasource_id: number | string;
datasource_type: string; datasource_type: string;
form_data: string; form_data: string;
chart_id?: number; chart_id?: number;
@@ -36,7 +36,7 @@ const assembleEndpoint = (key?: string, tabId?: string) => {
}; };
const assemblePayload = ( const assemblePayload = (
datasourceId: number, datasourceId: number | string,
datasourceType: string, datasourceType: string,
formData: JsonObject, formData: JsonObject,
chartId?: number, chartId?: number,
@@ -53,7 +53,7 @@ const assemblePayload = (
}; };
export const postFormData = ( export const postFormData = (
datasourceId: number, datasourceId: number | string,
datasourceType: string, datasourceType: string,
formData: JsonObject, formData: JsonObject,
chartId?: number, chartId?: number,
@@ -70,7 +70,7 @@ export const postFormData = (
}).then((r: JsonResponse) => r.json.key); }).then((r: JsonResponse) => r.json.key);
export const putFormData = ( export const putFormData = (
datasourceId: number, datasourceId: number | string,
datasourceType: string, datasourceType: string,
key: string, key: string,
formData: JsonObject, formData: JsonObject,

View File

@@ -18,6 +18,7 @@ import contextlib
import logging import logging
from abc import ABC from abc import ABC
from typing import Any, cast, Optional from typing import Any, cast, Optional
from uuid import UUID
from flask import request from flask import request
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
@@ -100,9 +101,12 @@ class GetExploreCommand(BaseCommand, ABC):
use_slice_data=True, use_slice_data=True,
initial_form_data=initial_form_data, initial_form_data=initial_form_data,
) )
ds_id: int | UUID | None = None
try: try:
self._datasource_id, self._datasource_type = get_datasource_info( ds_id, self._datasource_type = get_datasource_info(
self._datasource_id, self._datasource_type, form_data self._datasource_id,
self._datasource_type,
form_data,
) )
except SupersetException: except SupersetException:
self._datasource_id = None self._datasource_id = None
@@ -111,10 +115,11 @@ class GetExploreCommand(BaseCommand, ABC):
datasource: Optional[BaseDatasource] = None datasource: Optional[BaseDatasource] = None
if self._datasource_id is not None: if ds_id is not None:
with contextlib.suppress(DatasourceNotFound): with contextlib.suppress(DatasourceNotFound):
datasource = DatasourceDAO.get_datasource( datasource = DatasourceDAO.get_datasource(
cast(str, self._datasource_type), self._datasource_id cast(str, self._datasource_type),
ds_id,
) )
datasource_name = _("[Missing Dataset]") datasource_name = _("[Missing Dataset]")
@@ -124,7 +129,11 @@ class GetExploreCommand(BaseCommand, ABC):
security_manager.raise_for_access(datasource=datasource) security_manager.raise_for_access(datasource=datasource)
viz_type = form_data.get("viz_type") viz_type = form_data.get("viz_type")
if not viz_type and datasource and getattr(datasource, "default_endpoint", None): if (
not viz_type
and datasource
and getattr(datasource, "default_endpoint", None)
):
raise WrongEndpointError(redirect=datasource.default_endpoint) raise WrongEndpointError(redirect=datasource.default_endpoint)
form_data["datasource"] = ( form_data["datasource"] = (

View File

@@ -14,14 +14,17 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@dataclass @dataclass
class CommandParameters: class CommandParameters:
permalink_key: Optional[str] permalink_key: str | None
form_data_key: Optional[str] form_data_key: str | None
datasource_id: Optional[int] datasource_id: int | str | None
datasource_type: Optional[str] datasource_type: str | None
slice_id: Optional[int] slice_id: Optional[int]

View File

@@ -1,99 +0,0 @@
"""
Script to create a Pandas semantic layer and Sales semantic view in Superset.
Run this inside the superset_app container:
python /app/superset/create_pandas_semantic_layer.py
"""
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING
# Add the Superset application directory to the Python path
sys.path.insert(0, "/app")
from superset.app import create_app
from superset.extensions import db
from superset.utils import json
if TYPE_CHECKING:
from superset.semantic_layers.models import SemanticLayer, SemanticView
app = create_app()
app.app_context().push()
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
def create_pandas_semantic_layer() -> SemanticLayer:
"""Create a Pandas semantic layer with minimal configuration."""
from superset.semantic_layers.models import SemanticLayer
logger.info("Creating Pandas semantic layer...")
configuration = {
"dataset": "sales",
}
semantic_layer = SemanticLayer(
name="Pandas Semantic Layer",
description="In-memory semantic layer backed by a Pandas DataFrame",
type="pandas",
configuration=json.dumps(configuration),
cache_timeout=3600,
)
db.session.add(semantic_layer)
db.session.commit()
logger.info("Created semantic layer:")
logger.info(" Name: %s", semantic_layer.name)
logger.info(" UUID: %s", semantic_layer.uuid)
logger.info(" Type: %s", semantic_layer.type)
return semantic_layer
def create_sales_semantic_view(semantic_layer: SemanticLayer) -> SemanticView:
"""Create the Sales semantic view."""
from superset.semantic_layers.models import SemanticView
logger.info("Creating Sales semantic view...")
semantic_view = SemanticView(
name="sales",
configuration="{}",
cache_timeout=1800,
semantic_layer_uuid=semantic_layer.uuid,
)
db.session.add(semantic_view)
db.session.commit()
logger.info("Created semantic view:")
logger.info(" Name: %s", semantic_view.name)
logger.info(" UUID: %s", semantic_view.uuid)
logger.info(" Semantic Layer UUID: %s", semantic_view.semantic_layer_uuid)
return semantic_view
def main() -> None:
"""Main script execution."""
logger.info("=" * 60)
logger.info("Creating Pandas Semantic Layer and Sales Semantic View")
logger.info("=" * 60)
semantic_layer = create_pandas_semantic_layer()
create_sales_semantic_view(semantic_layer)
if __name__ == "__main__":
main()

View File

@@ -16,8 +16,8 @@
# under the License. # under the License.
import logging import logging
import uuid
from typing import Union from typing import Union
from uuid import UUID
from superset import db from superset import db
from superset.connectors.sqla.models import SqlaTable from superset.connectors.sqla.models import SqlaTable
@@ -48,7 +48,7 @@ class DatasourceDAO(BaseDAO[Datasource]):
def get_datasource( def get_datasource(
cls, cls,
datasource_type: Union[DatasourceType, str], datasource_type: Union[DatasourceType, str],
database_id_or_uuid: int | str, database_id_or_uuid: int | str | UUID,
) -> Datasource: ) -> Datasource:
if datasource_type not in cls.sources: if datasource_type not in cls.sources:
raise DatasourceTypeNotSupportedError() raise DatasourceTypeNotSupportedError()
@@ -59,7 +59,7 @@ class DatasourceDAO(BaseDAO[Datasource]):
filter = model.id == int(database_id_or_uuid) filter = model.id == int(database_id_or_uuid)
else: else:
try: try:
uuid.UUID(str(database_id_or_uuid)) # uuid validation UUID(str(database_id_or_uuid)) # uuid validation
filter = model.uuid == database_id_or_uuid filter = model.uuid == database_id_or_uuid
except ValueError as err: except ValueError as err:
logger.warning( logger.warning(

View File

@@ -109,7 +109,7 @@ class ExploreRestApi(BaseSupersetApi):
params = CommandParameters( params = CommandParameters(
permalink_key=request.args.get("permalink_key", type=str), permalink_key=request.args.get("permalink_key", type=str),
form_data_key=request.args.get("form_data_key", type=str), form_data_key=request.args.get("form_data_key", type=str),
datasource_id=request.args.get("datasource_id", type=int), datasource_id=request.args.get("datasource_id"),
datasource_type=request.args.get("datasource_type", type=str), datasource_type=request.args.get("datasource_type", type=str),
slice_id=request.args.get("slice_id", type=int), slice_id=request.args.get("slice_id", type=int),
) )

View File

@@ -25,7 +25,6 @@ Create Date: 2025-11-04 11:26:00.000000
import uuid import uuid
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from sqlalchemy_utils import UUIDType from sqlalchemy_utils import UUIDType
from sqlalchemy_utils.types.json import JSONType from sqlalchemy_utils.types.json import JSONType
@@ -83,7 +82,6 @@ def upgrade():
create_table( create_table(
"semantic_views", "semantic_views",
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False), sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, nullable=False),
sa.Column("id", sa.Integer(), sa.Identity(), unique=True, nullable=False),
sa.Column("created_on", sa.DateTime(), nullable=True), sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("changed_on", sa.DateTime(), nullable=True), sa.Column("changed_on", sa.DateTime(), nullable=True),
sa.Column("name", sa.String(length=250), nullable=False), sa.Column("name", sa.String(length=250), nullable=False),
@@ -123,22 +121,6 @@ def upgrade():
) )
# 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(): def downgrade():
# 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_views")
drop_table("semantic_layers") drop_table("semantic_layers")

View File

@@ -32,6 +32,7 @@ import numpy as np
from superset_core.semantic_layers.semantic_view import SemanticViewFeature from superset_core.semantic_layers.semantic_view import SemanticViewFeature
from superset_core.semantic_layers.types import ( from superset_core.semantic_layers.types import (
AdhocExpression, AdhocExpression,
AdhocFilter,
Day, Day,
Dimension, Dimension,
Filter, Filter,
@@ -369,14 +370,14 @@ def _get_filters_from_query_object(
query_object: ValidatedQueryObject, query_object: ValidatedQueryObject,
time_offset: str | None, time_offset: str | None,
all_dimensions: dict[str, Dimension], all_dimensions: dict[str, Dimension],
) -> set[Filter]: ) -> set[Filter | AdhocFilter]:
""" """
Extract all filters from the query object, including time range filters. 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 This simplifies the complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm
by converting all time constraints into filters. by converting all time constraints into filters.
""" """
filters: set[Filter] = set() filters: set[Filter | AdhocFilter] = set()
# 1. Add fetch values predicate if present # 1. Add fetch values predicate if present
if ( if (
@@ -384,11 +385,9 @@ def _get_filters_from_query_object(
and query_object.datasource.fetch_values_predicate and query_object.datasource.fetch_values_predicate
): ):
filters.add( filters.add(
Filter( AdhocFilter(
type=PredicateType.WHERE, type=PredicateType.WHERE,
column=None, definition=query_object.datasource.fetch_values_predicate,
operator=Operator.ADHOC,
value=query_object.datasource.fetch_values_predicate,
) )
) )
@@ -416,7 +415,7 @@ def _get_filters_from_query_object(
return filters return filters
def _get_filters_from_extras(extras: dict[str, Any]) -> set[Filter]: def _get_filters_from_extras(extras: dict[str, Any]) -> set[AdhocFilter]:
""" """
Extract filters from the extras dict. Extract filters from the extras dict.
@@ -431,29 +430,25 @@ def _get_filters_from_extras(extras: dict[str, Any]) -> set[Filter]:
Handled in _convert_time_grain() and used for dimension grain matching Handled in _convert_time_grain() and used for dimension grain matching
Note: The WHERE and HAVING clauses from extras are SQL expressions that 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. are passed through as-is to the semantic layer as AdhocFilter objects.
""" """
filters: set[Filter] = set() filters: set[AdhocFilter] = set()
# Add WHERE clause from extras # Add WHERE clause from extras
if where_clause := extras.get("where"): if where_clause := extras.get("where"):
filters.add( filters.add(
Filter( AdhocFilter(
type=PredicateType.WHERE, type=PredicateType.WHERE,
column=None, definition=where_clause,
operator=Operator.ADHOC,
value=where_clause,
) )
) )
# Add HAVING clause from extras # Add HAVING clause from extras
if having_clause := extras.get("having"): if having_clause := extras.get("having"):
filters.add( filters.add(
Filter( AdhocFilter(
type=PredicateType.HAVING, type=PredicateType.HAVING,
column=None, definition=having_clause,
operator=Operator.ADHOC,
value=having_clause,
) )
) )
@@ -545,7 +540,7 @@ def _convert_query_object_filter(
all_dimensions: dict[str, Dimension], all_dimensions: dict[str, Dimension],
) -> set[Filter] | None: ) -> set[Filter] | None:
""" """
Convert a QueryObject filter dict to a semantic layer Filter. Convert a QueryObject filter dict to a semantic layer Filter or AdhocFilter.
""" """
operator_str = filter_["op"] operator_str = filter_["op"]
@@ -569,6 +564,7 @@ def _convert_query_object_filter(
if operator_str == FilterOperator.TEMPORAL_RANGE.value: if operator_str == FilterOperator.TEMPORAL_RANGE.value:
if not isinstance(value, str) or value == NO_TIME_RANGE: if not isinstance(value, str) or value == NO_TIME_RANGE:
return None return None
start, end = value.split(" : ") start, end = value.split(" : ")
return { return {
Filter( Filter(
@@ -681,7 +677,7 @@ def _get_group_limit_from_query_object(
def _get_group_limit_filters( def _get_group_limit_filters(
query_object: ValidatedQueryObject, query_object: ValidatedQueryObject,
all_dimensions: dict[str, Dimension], all_dimensions: dict[str, Dimension],
) -> set[Filter] | None: ) -> set[Filter | AdhocFilter] | None:
""" """
Get separate filters for the group limit subquery if needed. Get separate filters for the group limit subquery if needed.
@@ -704,7 +700,7 @@ def _get_group_limit_filters(
return None return None
# Create separate filters for the group limit subquery # Create separate filters for the group limit subquery
filters: set[Filter] = set() filters: set[Filter | AdhocFilter] = set()
# Add time range filter using inner bounds # Add time range filter using inner bounds
if query_object.granularity: if query_object.granularity:
@@ -737,11 +733,9 @@ def _get_group_limit_filters(
and query_object.datasource.fetch_values_predicate and query_object.datasource.fetch_values_predicate
): ):
filters.add( filters.add(
Filter( AdhocFilter(
type=PredicateType.WHERE, type=PredicateType.WHERE,
column=None, definition=query_object.datasource.fetch_values_predicate,
operator=Operator.ADHOC,
value=query_object.datasource.fetch_values_predicate,
) )
) )

View File

@@ -26,7 +26,7 @@ from functools import cached_property
from typing import Any, TYPE_CHECKING from typing import Any, TYPE_CHECKING
from flask_appbuilder import Model from flask_appbuilder import Model
from sqlalchemy import Column, ForeignKey, Identity, Integer, String, Text from sqlalchemy import Column, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType from sqlalchemy_utils import UUIDType
from sqlalchemy_utils.types.json import JSONType from sqlalchemy_utils.types.json import JSONType
@@ -161,7 +161,6 @@ class SemanticView(AuditMixinNullable, Model):
__tablename__ = "semantic_views" __tablename__ = "semantic_views"
uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4) uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4)
id = Column(Integer, Identity(), unique=True)
# Core fields # Core fields
name = Column(String(250), nullable=False) name = Column(String(250), nullable=False)
@@ -248,7 +247,7 @@ class SemanticView(AuditMixinNullable, Model):
def data(self) -> ExplorableData: def data(self) -> ExplorableData:
return { return {
# core # core
"id": self.id, "id": self.uuid.hex,
"uid": self.uid, "uid": self.uid,
"type": "semantic_view", "type": "semantic_view",
"name": self.name, "name": self.name,
@@ -336,9 +335,6 @@ class SemanticView(AuditMixinNullable, Model):
"health_check_message": None, "health_check_message": None,
} }
def data_for_slices(self, slices: list[Any]) -> dict[str, Any]:
return self.data
def get_extra_cache_keys(self, query_obj: QueryObjectDict) -> list[Hashable]: def get_extra_cache_keys(self, query_obj: QueryObjectDict) -> list[Hashable]:
return [] return []
@@ -346,26 +342,6 @@ class SemanticView(AuditMixinNullable, Model):
def perm(self) -> str: def perm(self) -> str:
return self.semantic_layer_uuid.hex + "::" + self.uuid.hex 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 @property
def offset(self) -> int: def offset(self) -> int:
# always return datetime as UTC # always return datetime as UTC

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,7 @@ import re
from datetime import datetime from datetime import datetime
from typing import Any, Callable, cast from typing import Any, Callable, cast
from urllib import parse from urllib import parse
from uuid import UUID
from flask import ( from flask import (
abort, abort,
@@ -169,9 +170,9 @@ class Superset(BaseSupersetView):
if viz_obj.has_error(payload): if viz_obj.has_error(payload):
return json_error_response(payload=payload, status=400) return json_error_response(payload=payload, status=400)
response = { response = {
"data": payload["df"].to_dict("records") "data": (
if payload["df"] is not None payload["df"].to_dict("records") if payload["df"] is not None else []
else [], ),
"colnames": payload.get("colnames"), "colnames": payload.get("colnames"),
"coltypes": payload.get("coltypes"), "coltypes": payload.get("coltypes"),
"rowcount": payload.get("rowcount"), "rowcount": payload.get("rowcount"),
@@ -268,7 +269,9 @@ class Superset(BaseSupersetView):
@check_resource_permissions(check_datasource_perms) @check_resource_permissions(check_datasource_perms)
@deprecated(eol_version="5.0.0") @deprecated(eol_version="5.0.0")
def explore_json( def explore_json(
self, datasource_type: str | None = None, datasource_id: int | None = None self,
datasource_type: str | None = None,
datasource_id: int | str | None = None,
) -> FlaskResponse: ) -> FlaskResponse:
"""Serves all request that GET or POST form_data """Serves all request that GET or POST form_data
@@ -302,8 +305,10 @@ class Superset(BaseSupersetView):
form_data = get_form_data()[0] form_data = get_form_data()[0]
try: try:
datasource_id, datasource_type = get_datasource_info( ds_id, datasource_type = get_datasource_info(
datasource_id, datasource_type, form_data datasource_id,
datasource_type,
form_data,
) )
force = request.args.get("force") == "true" force = request.args.get("force") == "true"
@@ -316,7 +321,7 @@ class Superset(BaseSupersetView):
with contextlib.suppress(CacheLoadError): with contextlib.suppress(CacheLoadError):
viz_obj = get_viz( viz_obj = get_viz(
datasource_type=cast(str, datasource_type), datasource_type=cast(str, datasource_type),
datasource_id=datasource_id, datasource_id=ds_id,
form_data=form_data, form_data=form_data,
force_cached=True, force_cached=True,
force=force, force=force,
@@ -343,7 +348,7 @@ class Superset(BaseSupersetView):
viz_obj = get_viz( viz_obj = get_viz(
datasource_type=cast(str, datasource_type), datasource_type=cast(str, datasource_type),
datasource_id=datasource_id, datasource_id=ds_id,
form_data=form_data, form_data=form_data,
force=force, force=force,
) )
@@ -407,7 +412,7 @@ class Superset(BaseSupersetView):
def explore( # noqa: C901 def explore( # noqa: C901
self, self,
datasource_type: str | None = None, datasource_type: str | None = None,
datasource_id: int | None = None, datasource_id: int | str | None = None,
key: str | None = None, key: str | None = None,
) -> FlaskResponse: ) -> FlaskResponse:
if request.method == "GET": if request.method == "GET":
@@ -451,21 +456,23 @@ class Superset(BaseSupersetView):
query_context = request.form.get("query_context") query_context = request.form.get("query_context")
ds_id: int | UUID | None = None
try: try:
datasource_id, datasource_type = get_datasource_info( ds_id, datasource_type = get_datasource_info(
datasource_id, datasource_type, form_data datasource_id,
datasource_type,
form_data,
) )
except SupersetException: except SupersetException:
datasource_id = None
# fallback unknown datasource to table type # fallback unknown datasource to table type
datasource_type = SqlaTable.type datasource_type = SqlaTable.type
datasource: BaseDatasource | None = None datasource: BaseDatasource | None = None
if datasource_id is not None: if ds_id is not None:
with contextlib.suppress(DatasetNotFoundError): with contextlib.suppress(DatasetNotFoundError):
datasource = DatasourceDAO.get_datasource( datasource = DatasourceDAO.get_datasource(
DatasourceType("table"), DatasourceType("table"),
datasource_id, ds_id,
) )
datasource_name = datasource.name if datasource else _("[Missing Dataset]") datasource_name = datasource.name if datasource else _("[Missing Dataset]")

View File

@@ -14,12 +14,16 @@
# KIND, either express or implied. See the License for the # KIND, either express or implied. See the License for the
# specific language governing permissions and limitations # specific language governing permissions and limitations
# under the License. # under the License.
from __future__ import annotations
import contextlib import contextlib
import logging import logging
from collections import defaultdict from collections import defaultdict
from functools import wraps from functools import wraps
from typing import Any, Callable, DefaultDict, Optional, Union from typing import Any, Callable, DefaultDict
from urllib import parse from urllib import parse
from uuid import UUID
import msgpack import msgpack
import pyarrow as pa import pyarrow as pa
@@ -163,7 +167,7 @@ def get_permissions(
def get_viz( def get_viz(
form_data: FormData, form_data: FormData,
datasource_type: str, datasource_type: str,
datasource_id: int, datasource_id: int | UUID,
force: bool = False, force: bool = False,
force_cached: bool = False, force_cached: bool = False,
) -> BaseViz: ) -> BaseViz:
@@ -186,10 +190,10 @@ def loads_request_json(request_json_data: str) -> dict[Any, Any]:
def get_form_data( def get_form_data(
slice_id: Optional[int] = None, slice_id: int | None = None,
use_slice_data: bool = False, use_slice_data: bool = False,
initial_form_data: Optional[dict[str, Any]] = None, initial_form_data: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], Optional[Slice]]: ) -> tuple[dict[str, Any], Slice | None]:
form_data: dict[str, Any] = initial_form_data or {} form_data: dict[str, Any] = initial_form_data or {}
if has_request_context(): if has_request_context():
@@ -272,8 +276,10 @@ def add_sqllab_custom_filters(form_data: dict[Any, Any]) -> Any:
def get_datasource_info( def get_datasource_info(
datasource_id: Optional[int], datasource_type: Optional[str], form_data: FormData datasource_id: int | str | None,
) -> tuple[int, Optional[str]]: datasource_type: str | None,
form_data: FormData,
) -> tuple[int | UUID, str | None]:
""" """
Compatibility layer for handling of datasource info Compatibility layer for handling of datasource info
@@ -300,12 +306,16 @@ def get_datasource_info(
_("The dataset associated with this chart no longer exists") _("The dataset associated with this chart no longer exists")
) )
datasource_id = int(datasource_id) # Convert datasource_id to appropriate type
return datasource_id, datasource_type if isinstance(datasource_id, int):
return datasource_id, datasource_type
if datasource_id.isdigit():
return int(datasource_id), datasource_type
return UUID(datasource_id), datasource_type
def apply_display_max_row_limit( def apply_display_max_row_limit(
sql_results: dict[str, Any], rows: Optional[int] = None sql_results: dict[str, Any], rows: int | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Given a `sql_results` nested structure, applies a limit to the number of rows Given a `sql_results` nested structure, applies a limit to the number of rows
@@ -482,8 +492,8 @@ def check_explore_cache_perms(_self: Any, cache_key: str) -> None:
def check_datasource_perms( def check_datasource_perms(
_self: Any, _self: Any,
datasource_type: Optional[str] = None, datasource_type: str | None = None,
datasource_id: Optional[int] = None, datasource_id: int | str | None = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
""" """
@@ -500,8 +510,10 @@ def check_datasource_perms(
form_data = kwargs["form_data"] if "form_data" in kwargs else get_form_data()[0] form_data = kwargs["form_data"] if "form_data" in kwargs else get_form_data()[0]
try: try:
datasource_id, datasource_type = get_datasource_info( ds_id, datasource_type = get_datasource_info(
datasource_id, datasource_type, form_data datasource_id,
datasource_type,
form_data,
) )
except SupersetException as ex: except SupersetException as ex:
raise SupersetSecurityException( raise SupersetSecurityException(
@@ -524,7 +536,7 @@ def check_datasource_perms(
try: try:
viz_obj = get_viz( viz_obj = get_viz(
datasource_type=datasource_type, datasource_type=datasource_type,
datasource_id=datasource_id, datasource_id=ds_id,
form_data=form_data, form_data=form_data,
force=False, force=False,
) )
@@ -541,7 +553,9 @@ def check_datasource_perms(
def _deserialize_results_payload( def _deserialize_results_payload(
payload: Union[bytes, str], query: Query, use_msgpack: Optional[bool] = False payload: bytes | str,
query: Query,
use_msgpack: bool | None = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
logger.debug("Deserializing from msgpack: %r", use_msgpack) logger.debug("Deserializing from msgpack: %r", use_msgpack)
if use_msgpack: if use_msgpack:
@@ -579,9 +593,12 @@ def _deserialize_results_payload(
def get_cta_schema_name( def get_cta_schema_name(
database: Database, user: ab_models.User, schema: str, sql: str database: Database,
) -> Optional[str]: user: ab_models.User,
func: Optional[Callable[[Database, ab_models.User, str, str], str]] = app.config[ schema: str,
sql: str,
) -> str | None:
func: Callable[[Database, ab_models.User, str, str], str] | None = app.config[
"SQLLAB_CTAS_SCHEMA_NAME_FUNC" "SQLLAB_CTAS_SCHEMA_NAME_FUNC"
] ]
if not func: if not func:

View File

@@ -24,6 +24,7 @@ from pytest_mock import MockerFixture
from superset_core.semantic_layers.semantic_view import SemanticViewFeature from superset_core.semantic_layers.semantic_view import SemanticViewFeature
from superset_core.semantic_layers.types import ( from superset_core.semantic_layers.types import (
AdhocExpression, AdhocExpression,
AdhocFilter,
Day, Day,
Dimension, Dimension,
Filter, Filter,
@@ -201,11 +202,9 @@ def test_get_filters_from_extras_where() -> None:
assert len(result) == 1 assert len(result) == 1
filter_ = next(iter(result)) filter_ = next(iter(result))
assert isinstance(filter_, Filter) assert isinstance(filter_, AdhocFilter)
assert filter_.type == PredicateType.WHERE assert filter_.type == PredicateType.WHERE
assert filter_.column is None assert filter_.definition == "customer_id > 100"
assert filter_.operator == Operator.ADHOC
assert filter_.value == "customer_id > 100"
def test_get_filters_from_extras_having() -> None: def test_get_filters_from_extras_having() -> None:
@@ -216,12 +215,7 @@ def test_get_filters_from_extras_having() -> None:
result = _get_filters_from_extras(extras) result = _get_filters_from_extras(extras)
assert result == { assert result == {
Filter( AdhocFilter(type=PredicateType.HAVING, definition="SUM(sales) > 1000"),
type=PredicateType.HAVING,
column=None,
operator=Operator.ADHOC,
value="SUM(sales) > 1000",
),
} }
@@ -236,18 +230,8 @@ def test_get_filters_from_extras_both() -> None:
result = _get_filters_from_extras(extras) result = _get_filters_from_extras(extras)
assert result == { assert result == {
Filter( AdhocFilter(type=PredicateType.WHERE, definition="region = 'US'"),
type=PredicateType.WHERE, AdhocFilter(type=PredicateType.HAVING, definition="COUNT(*) > 10"),
column=None,
operator=Operator.ADHOC,
value="region = 'US'",
),
Filter(
type=PredicateType.HAVING,
column=None,
operator=Operator.ADHOC,
value="COUNT(*) > 10",
),
} }
@@ -466,11 +450,9 @@ def test_get_filters_from_query_object_with_extras(mock_datasource: MagicMock) -
operator=Operator.LESS_THAN, operator=Operator.LESS_THAN,
value=datetime(2025, 10, 22), value=datetime(2025, 10, 22),
), ),
Filter( AdhocFilter(
type=PredicateType.WHERE, type=PredicateType.WHERE,
column=None, definition="customer_id > 100",
operator=Operator.ADHOC,
value="customer_id > 100",
), ),
} }
@@ -512,11 +494,9 @@ def test_get_filters_from_query_object_with_fetch_values(
operator=Operator.LESS_THAN, operator=Operator.LESS_THAN,
value=datetime(2025, 10, 22), value=datetime(2025, 10, 22),
), ),
Filter( AdhocFilter(
type=PredicateType.WHERE, type=PredicateType.WHERE,
column=None, definition="tenant_id = 123",
operator=Operator.ADHOC,
value="tenant_id = 123",
), ),
} }
@@ -816,11 +796,9 @@ def test_get_group_limit_filters_with_extras(mock_datasource: MagicMock) -> None
operator=Operator.LESS_THAN, operator=Operator.LESS_THAN,
value=datetime(2025, 10, 22), value=datetime(2025, 10, 22),
), ),
Filter( AdhocFilter(
type=PredicateType.WHERE, type=PredicateType.WHERE,
column=None, definition="customer_id > 100",
operator=Operator.ADHOC,
value="customer_id > 100",
), ),
} }
@@ -2041,11 +2019,9 @@ def test_get_group_limit_filters_with_fetch_values_predicate(
assert result is not None assert result is not None
assert ( assert (
Filter( AdhocFilter(
type=PredicateType.WHERE, type=PredicateType.WHERE,
column=None, definition="tenant_id = 123",
operator=Operator.ADHOC,
value="tenant_id = 123",
) )
in result in result
) )
@@ -2396,7 +2372,6 @@ def test_get_filters_from_query_object_with_filter_loop(
f f
for f in result for f in result
if isinstance(f, Filter) if isinstance(f, Filter)
and f.column
and f.column.name == "category" and f.column.name == "category"
and f.operator == Operator.EQUALS and f.operator == Operator.EQUALS
] ]
@@ -2469,7 +2444,6 @@ def test_get_group_limit_filters_with_filter_loop(
f f
for f in result for f in result
if isinstance(f, Filter) if isinstance(f, Filter)
and f.column
and f.column.name == "category" and f.column.name == "category"
and f.operator == Operator.EQUALS and f.operator == Operator.EQUALS
] ]
@@ -2581,7 +2555,6 @@ def test_get_filters_from_query_object_filter_returns_none(
f f
for f in result for f in result
if isinstance(f, Filter) if isinstance(f, Filter)
and f.column
and f.column.name == "category" and f.column.name == "category"
and f.operator == Operator.EQUALS and f.operator == Operator.EQUALS
] ]
@@ -2634,7 +2607,6 @@ def test_get_group_limit_filters_filter_returns_none(
f f
for f in result for f in result
if isinstance(f, Filter) if isinstance(f, Filter)
and f.column
and f.column.name == "category" and f.column.name == "category"
and f.operator == Operator.EQUALS and f.operator == Operator.EQUALS
] ]