mirror of
https://github.com/apache/superset.git
synced 2026-05-04 07:24:18 +00:00
Compare commits
4 Commits
semantic-l
...
semantic-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11ac4dd90 | ||
|
|
e182520bb3 | ||
|
|
bfa4d5bd92 | ||
|
|
0e9c71e283 |
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface Currency {
|
||||
* Datasource metadata.
|
||||
*/
|
||||
export interface Datasource {
|
||||
id: number | string;
|
||||
id: number;
|
||||
name: string;
|
||||
type: DatasourceType;
|
||||
columns: Column[];
|
||||
|
||||
@@ -159,7 +159,7 @@ export interface QueryObject
|
||||
|
||||
export interface QueryContext {
|
||||
datasource: {
|
||||
id: number | string;
|
||||
id: number;
|
||||
type: DatasourceType;
|
||||
};
|
||||
/** Force refresh of all queries */
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] = (
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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]")
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user