Compare commits

..

4 Commits

Author SHA1 Message Date
Beto Dealmeida
b11ac4dd90 chore: container for testing 2026-02-10 15:39:50 -05:00
Beto Dealmeida
e182520bb3 feat: Explore integration 2026-02-10 15:39:50 -05:00
Beto Dealmeida
bfa4d5bd92 feat: models and DAOs 2026-02-10 15:38:15 -05:00
Beto Dealmeida
0e9c71e283 chore: remove AdhocFilter 2026-02-10 11:23:53 -05:00
16 changed files with 132 additions and 109 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ Create Date: 2025-11-04 11:26:00.000000
import uuid
import sqlalchemy as sa
from alembic import op
from sqlalchemy_utils import UUIDType
from sqlalchemy_utils.types.json import JSONType
@@ -82,6 +83,7 @@ def upgrade():
create_table(
"semantic_views",
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("changed_on", sa.DateTime(), nullable=True),
sa.Column("name", sa.String(length=250), nullable=False),
@@ -121,6 +123,22 @@ 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():
# Restore original constraint
with op.batch_alter_table("slices") as batch_op:
batch_op.drop_constraint("ck_chart_datasource", type_="check")
batch_op.create_check_constraint(
"ck_chart_datasource", "datasource_type in ('table')"
)
drop_table("semantic_views")
drop_table("semantic_layers")

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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