mirror of
https://github.com/apache/superset.git
synced 2026-08-13 19:50:39 +00:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e917a18e7 | ||
|
|
76fd626d57 | ||
|
|
df2cad1aed | ||
|
|
278e982ab0 | ||
|
|
e6beebc88c | ||
|
|
8a9cca546b | ||
|
|
9997cdeb62 | ||
|
|
ff20d991ab | ||
|
|
97a0eb5ffa | ||
|
|
3891cfeeb3 | ||
|
|
97e52d9485 | ||
|
|
debcde2057 | ||
|
|
f6be0b4dea | ||
|
|
836dddafc6 | ||
|
|
fb39bcbde3 | ||
|
|
0348fe93bd | ||
|
|
9215d3f064 | ||
|
|
4aa4985562 | ||
|
|
d07e209a9d | ||
|
|
29e335aa3e | ||
|
|
e3dec47a5e | ||
|
|
0cc1f46516 | ||
|
|
7aa9c63b66 | ||
|
|
5ba6db46a7 | ||
|
|
d525b05d71 | ||
|
|
d14bcba501 | ||
|
|
15d286aacf | ||
|
|
9e16d111fb | ||
|
|
297bd1e732 | ||
|
|
bfa930a3ac | ||
|
|
befcf96027 | ||
|
|
6f6567d5c9 | ||
|
|
3fb58b996a | ||
|
|
ffae2063e2 | ||
|
|
e1899f1014 | ||
|
|
dfc6aad5f0 | ||
|
|
837ea2a07f | ||
|
|
b83596893a | ||
|
|
a7e446d2ff | ||
|
|
ccbdc2359e | ||
|
|
7e40403287 | ||
|
|
4d83840f81 | ||
|
|
4c77a527c5 | ||
|
|
52b1530666 | ||
|
|
70fd9ff617 | ||
|
|
ae415b93d5 | ||
|
|
15bfab6b1e | ||
|
|
8e31c93119 | ||
|
|
4974c08f7d | ||
|
|
fa90ba976c | ||
|
|
35c3d8dfbc | ||
|
|
ee23815aff | ||
|
|
7c946ae3db | ||
|
|
3926f5c55c | ||
|
|
fdc03d4bf3 | ||
|
|
24f0aed8a7 | ||
|
|
00d2f577df | ||
|
|
c35fc71bc5 | ||
|
|
1b6d57c3f3 | ||
|
|
d089a96163 | ||
|
|
0b3fe3d60c | ||
|
|
0eeb184b6a | ||
|
|
8e7edce616 | ||
|
|
754201b3d0 | ||
|
|
925401b4e1 | ||
|
|
8368ea4094 | ||
|
|
e8a6fb24ae | ||
|
|
311b7a72dc | ||
|
|
aa496def53 | ||
|
|
aea4375255 | ||
|
|
9ab0a0179d | ||
|
|
3db613dab5 | ||
|
|
de5ca79805 | ||
|
|
aede3bb5ba | ||
|
|
408f84aea6 | ||
|
|
92c07aaf54 | ||
|
|
f405174fcf | ||
|
|
8c125d2553 | ||
|
|
fb8fca4c64 | ||
|
|
dc0c055518 | ||
|
|
09349cb1e7 | ||
|
|
ca29adb0cb |
@@ -0,0 +1,359 @@
|
||||
# Chart Data Request Flow in Apache Superset
|
||||
|
||||
This document traces the complete path of a chart data request through the Superset backend, from API endpoint to database query and back.
|
||||
|
||||
## Overview
|
||||
|
||||
When a client requests chart data (e.g., loading a histogram chart), the request flows through multiple layers:
|
||||
|
||||
1. API Endpoint
|
||||
2. Schema Validation/Parsing
|
||||
3. Command Pattern (Business Logic)
|
||||
4. Query Context Processing
|
||||
5. Database Execution
|
||||
6. Post-Processing
|
||||
7. Response Formatting
|
||||
|
||||
## Detailed Flow
|
||||
|
||||
### 1. Entry Point: API Endpoint
|
||||
|
||||
**File**: `superset/charts/data/api.py:187`
|
||||
|
||||
**Endpoint**: `POST /api/v1/chart/data`
|
||||
|
||||
The request hits `ChartDataRestApi.data()` method which:
|
||||
- Parses the JSON body from the request
|
||||
- Creates a `QueryContext` object from the form data via `ChartDataQueryContextSchema`
|
||||
- Creates a `ChartDataCommand` to execute the query
|
||||
- Validates and executes the command
|
||||
|
||||
```python
|
||||
def data(self) -> Response:
|
||||
json_body = request.json
|
||||
query_context = self._create_query_context_from_form(json_body)
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
return self._get_data_response(command, ...)
|
||||
```
|
||||
|
||||
### 2. Schema Layer: Request Parsing
|
||||
|
||||
**File**: `superset/charts/schemas.py:1384`
|
||||
|
||||
`ChartDataQueryContextSchema.load()` deserializes the request into:
|
||||
|
||||
**QueryContext object** (the main container):
|
||||
- datasource: Database table/query info
|
||||
- queries: List of query objects
|
||||
- result_format: JSON/CSV/XLSX
|
||||
- result_type: FULL/SAMPLES/QUERY/etc
|
||||
- force: Whether to bypass cache
|
||||
|
||||
**List of QueryObject instances** (one per query in the request):
|
||||
- columns: Columns to select (e.g., ["age"])
|
||||
- metrics: Aggregations to compute
|
||||
- filters: WHERE clause filters
|
||||
- post_processing: Client-side transformations (e.g., histogram with bins=25)
|
||||
|
||||
### 3. Command Pattern: Business Logic
|
||||
|
||||
**File**: `superset/commands/chart/data/get_data_command.py:39`
|
||||
|
||||
`ChartDataCommand.run()` orchestrates the execution:
|
||||
|
||||
```python
|
||||
def run(self, **kwargs: Any) -> dict[str, Any]:
|
||||
payload = self._query_context.get_payload(
|
||||
cache_query_context=cache_query_context,
|
||||
force_cached=force_cached
|
||||
)
|
||||
|
||||
for query in payload["queries"]:
|
||||
if query.get("error"):
|
||||
raise ChartDataQueryFailedError(query["error"])
|
||||
|
||||
return {
|
||||
"query_context": self._query_context,
|
||||
"queries": payload["queries"]
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Query Context Processor: Core Execution
|
||||
|
||||
**File**: `superset/common/query_context_processor.py:1052`
|
||||
|
||||
`QueryContextProcessor.get_payload()`:
|
||||
- Iterates through each `QueryObject` in `query_context.queries`
|
||||
- For each query, calls `get_query_results()` which routes based on result_type:
|
||||
- `FULL` → `_get_full()` → `get_df_payload()`
|
||||
- `SAMPLES` → `_get_samples()`
|
||||
- `QUERY` → `_get_query()`
|
||||
|
||||
**File**: `superset/common/query_context_processor.py:128`
|
||||
|
||||
`QueryContextProcessor.get_df_payload()`:
|
||||
|
||||
1. **Generate cache key** from query object
|
||||
2. **Check cache** using `QueryCacheManager`
|
||||
3. **If cache miss**:
|
||||
- Validate columns exist in datasource
|
||||
- Call `get_query_result(query_obj)` to execute SQL
|
||||
- Get annotation data if needed
|
||||
- Cache the result with appropriate timeout
|
||||
4. **Return payload** with DataFrame and metadata
|
||||
|
||||
```python
|
||||
def get_df_payload(self, query_obj, force_cached=False):
|
||||
cache_key = self.query_cache_key(query_obj)
|
||||
timeout = self.get_cache_timeout()
|
||||
cache = QueryCacheManager.get(key=cache_key, ...)
|
||||
|
||||
if not cache.is_loaded:
|
||||
query_result = self.get_query_result(query_obj)
|
||||
annotation_data = self.get_annotation_data(query_obj)
|
||||
cache.set_query_result(...)
|
||||
|
||||
return {
|
||||
"cache_key": cache_key,
|
||||
"df": cache.df,
|
||||
"query": cache.query,
|
||||
"is_cached": cache.is_cached,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Database Query Execution
|
||||
|
||||
**File**: `superset/common/query_context_processor.py:267`
|
||||
|
||||
`QueryContextProcessor.get_query_result()`:
|
||||
|
||||
```python
|
||||
def get_query_result(self, query_object: QueryObject) -> QueryResult:
|
||||
# Execute SQL query on the datasource
|
||||
result = query_context.datasource.query(query_object.to_dict())
|
||||
df = result.df
|
||||
|
||||
# Normalize timestamps to pandas datetime format
|
||||
if not df.empty:
|
||||
df = self.normalize_df(df, query_object)
|
||||
|
||||
# Handle time offset comparisons if specified
|
||||
if query_object.time_offsets:
|
||||
time_offsets = self.processing_time_offsets(df, query_object)
|
||||
df = time_offsets["df"]
|
||||
|
||||
# Apply post-processing operations
|
||||
df = query_object.exec_post_processing(df)
|
||||
|
||||
result.df = df
|
||||
return result
|
||||
```
|
||||
|
||||
The `datasource.query()` call goes to your database connector (e.g., `SqlaTable.query()`) which:
|
||||
- Converts the QueryObject dict to SQL using SQLAlchemy
|
||||
- Executes the query via database engine
|
||||
- Returns a `QueryResult` with a pandas DataFrame
|
||||
|
||||
### 6. Post-Processing
|
||||
|
||||
**File**: `superset/common/query_object.py:484`
|
||||
|
||||
`QueryObject.exec_post_processing()`:
|
||||
- Applies operations from `post_processing` list in sequence
|
||||
- Each operation is a pandas transformation (e.g., pivot, aggregate, histogram)
|
||||
- Uses functions from `superset.utils.pandas_postprocessing`
|
||||
|
||||
Example for histogram:
|
||||
```python
|
||||
def exec_post_processing(self, df: DataFrame) -> DataFrame:
|
||||
for post_process in self.post_processing:
|
||||
operation = post_process.get("operation") # "histogram"
|
||||
options = post_process.get("options", {}) # {column: "age", bins: 25}
|
||||
df = getattr(pandas_postprocessing, operation)(df, **options)
|
||||
return df
|
||||
```
|
||||
|
||||
### 7. Response Formatting
|
||||
|
||||
**File**: `superset/charts/data/api.py:346`
|
||||
|
||||
`ChartDataRestApi._send_chart_response()`:
|
||||
- Takes the result dict from command
|
||||
- Formats based on `result_format`:
|
||||
- **JSON**: Converts DataFrame to list of dicts
|
||||
- **CSV**: Converts to CSV string
|
||||
- **XLSX**: Converts to Excel binary
|
||||
- Returns Flask Response with appropriate headers
|
||||
|
||||
```python
|
||||
def _send_chart_response(self, result, form_data=None, datasource=None):
|
||||
result_format = result["query_context"].result_format
|
||||
|
||||
if result_format == ChartDataResultFormat.JSON:
|
||||
queries = result["queries"]
|
||||
response_data = json.dumps(
|
||||
{"result": queries},
|
||||
default=json.json_int_dttm_ser,
|
||||
ignore_nan=True,
|
||||
)
|
||||
resp = make_response(response_data, 200)
|
||||
resp.headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
return resp
|
||||
```
|
||||
|
||||
## Key Objects and Data Structures
|
||||
|
||||
### QueryContext
|
||||
|
||||
**File**: `superset/common/query_context.py:41`
|
||||
|
||||
The main container for a chart data request.
|
||||
|
||||
```python
|
||||
{
|
||||
datasource: BaseDatasource, # Dataset (e.g., id=19, type="table")
|
||||
queries: list[QueryObject], # List of queries to execute
|
||||
result_type: ChartDataResultType, # "full", "samples", "query", etc.
|
||||
result_format: ChartDataResultFormat, # "json", "csv", "xlsx"
|
||||
force: bool, # Bypass cache flag
|
||||
form_data: dict, # Original form_data from client
|
||||
custom_cache_timeout: int | None # Override cache timeout
|
||||
}
|
||||
```
|
||||
|
||||
### QueryObject
|
||||
|
||||
**File**: `superset/common/query_object.py:79`
|
||||
|
||||
Represents a single database query.
|
||||
|
||||
```python
|
||||
{
|
||||
columns: list[Column], # Columns to select ["age"]
|
||||
metrics: list[Metric] | None, # Aggregations to compute
|
||||
filters: list[FilterClause], # WHERE clause filters
|
||||
extras: dict[str, Any], # Additional query options
|
||||
post_processing: list[dict], # Client-side transformations
|
||||
row_limit: int | None, # LIMIT clause
|
||||
row_offset: int, # OFFSET clause
|
||||
order_desc: bool, # Sort direction
|
||||
time_range: str | None, # Time filter range
|
||||
granularity: str | None, # Temporal grouping column
|
||||
annotation_layers: list[dict], # Annotations to overlay
|
||||
from_dttm: datetime | None, # Computed time range start
|
||||
to_dttm: datetime | None # Computed time range end
|
||||
}
|
||||
```
|
||||
|
||||
### QueryResult
|
||||
|
||||
**File**: `superset/models/helpers.py`
|
||||
|
||||
Returned from `datasource.query()`.
|
||||
|
||||
```python
|
||||
{
|
||||
df: pd.DataFrame, # The data from database
|
||||
query: str, # Executed SQL query
|
||||
from_dttm: datetime, # Time range start
|
||||
to_dttm: datetime, # Time range end
|
||||
error: str | None, # Error message if failed
|
||||
status: QueryStatus # success, failed, etc.
|
||||
}
|
||||
```
|
||||
|
||||
## Example Request Flow
|
||||
|
||||
For a histogram chart request like:
|
||||
|
||||
```bash
|
||||
curl 'https://example.com/api/v1/chart/data' \
|
||||
-H 'content-type: application/json' \
|
||||
--data-raw '{
|
||||
"datasource":{"id":19,"type":"table"},
|
||||
"queries":[{
|
||||
"columns":["age"],
|
||||
"filters":[{
|
||||
"col":"time_start",
|
||||
"op":"TEMPORAL_RANGE",
|
||||
"val":"No filter"
|
||||
}],
|
||||
"row_limit":10000,
|
||||
"post_processing":[{
|
||||
"operation":"histogram",
|
||||
"options":{"column":"age","bins":25}
|
||||
}]
|
||||
}],
|
||||
"result_format":"json",
|
||||
"result_type":"full"
|
||||
}'
|
||||
```
|
||||
|
||||
### Flow Summary
|
||||
|
||||
```
|
||||
Client Request (curl)
|
||||
↓
|
||||
ChartDataRestApi.data()
|
||||
↓ (parses JSON)
|
||||
ChartDataQueryContextSchema.load()
|
||||
↓ (creates objects)
|
||||
QueryContext + [QueryObject]
|
||||
↓
|
||||
ChartDataCommand.run()
|
||||
↓
|
||||
QueryContextProcessor.get_payload()
|
||||
↓ (for each QueryObject)
|
||||
get_query_results() → _get_full()
|
||||
↓
|
||||
get_df_payload()
|
||||
├→ Check Cache (QueryCacheManager)
|
||||
└→ get_query_result()
|
||||
├→ datasource.query() → Build SQL → Execute → pandas DataFrame
|
||||
├→ normalize_df() → Timestamp normalization
|
||||
└→ exec_post_processing() → Apply histogram operation
|
||||
↓
|
||||
Return payload {df, query, metadata}
|
||||
↓
|
||||
_send_chart_response()
|
||||
↓ (format as JSON)
|
||||
Flask Response → Client
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
The codebase follows clean separation of concerns:
|
||||
|
||||
1. **API Layer** (`superset/charts/data/api.py`): Handles HTTP requests/responses
|
||||
2. **Schema Layer** (`superset/charts/schemas.py`): Validates and deserializes input
|
||||
3. **Command Layer** (`superset/commands/`): Orchestrates business logic
|
||||
4. **Query Context/Processor** (`superset/common/`): Manages execution and caching
|
||||
5. **Query Object**: Represents individual database queries
|
||||
6. **Datasource Layer** (`superset/connectors/`): Database abstraction and SQL generation
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- **Caching**: Results cached at multiple levels (query result, query context)
|
||||
- **Security**: Access control enforced via `raise_for_access()`
|
||||
- **Flexibility**: Supports multiple result types and formats
|
||||
- **Post-processing**: Client-side transformations without re-querying database
|
||||
- **Time Comparison**: Built-in support for time offset queries
|
||||
- **Annotations**: Overlay additional data layers on charts
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
**File**: `superset/common/utils/query_cache_manager.py`
|
||||
|
||||
Cache keys are generated from:
|
||||
- Query object (columns, metrics, filters, etc.)
|
||||
- Datasource UID
|
||||
- RLS (Row Level Security) rules
|
||||
- User context (if per-user caching enabled)
|
||||
- Time range (using relative time strings, not absolute timestamps)
|
||||
|
||||
This ensures that:
|
||||
- Same query returns cached results
|
||||
- Different users see appropriate cached data
|
||||
- Time-relative queries (e.g., "Last 7 days") cache correctly
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
# Import all settings from the main config first
|
||||
from flask_caching.backends.filesystemcache import FileSystemCache
|
||||
|
||||
from superset_config import * # noqa: F403
|
||||
|
||||
# Override caching to use simple in-memory cache instead of Redis
|
||||
|
||||
@@ -182,7 +182,7 @@ The available validators and names can be found in
|
||||
|
||||
In this section, we'll walkthrough the pre-defined Jinja macros in Superset.
|
||||
|
||||
**Current Username**
|
||||
### Current Username
|
||||
|
||||
The `{{ current_username() }}` macro returns the `username` of the currently logged in user.
|
||||
|
||||
@@ -197,7 +197,7 @@ cache key by adding the following parameter to your Jinja code:
|
||||
{{ current_username(add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
**Current User ID**
|
||||
### Current User ID
|
||||
|
||||
The `{{ current_user_id() }}` macro returns the account ID of the currently logged in user.
|
||||
|
||||
@@ -212,7 +212,7 @@ cache key by adding the following parameter to your Jinja code:
|
||||
{{ current_user_id(add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
**Current User Email**
|
||||
### Current User Email
|
||||
|
||||
The `{{ current_user_email() }}` macro returns the email address of the currently logged in user.
|
||||
|
||||
@@ -227,7 +227,7 @@ cache key by adding the following parameter to your Jinja code:
|
||||
{{ current_user_email(add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
**Current User Roles**
|
||||
### Current User Roles
|
||||
|
||||
The `{{ current_user_roles() }}` macro returns an array of roles for the logged in user.
|
||||
|
||||
@@ -257,7 +257,7 @@ Will be rendered as:
|
||||
SELECT * FROM users WHERE role IN ('admin', 'viewer')
|
||||
```
|
||||
|
||||
**Current User RLS Rules**
|
||||
### Current User RLS Rules
|
||||
|
||||
The `{{ current_user_rls_rules() }}` macro returns an array of RLS rules applied to the current dataset for the logged in user.
|
||||
|
||||
@@ -265,7 +265,7 @@ If you have caching enabled in your Superset configuration, then the list of RLS
|
||||
by Superset when calculating the cache key. A cache key is a unique identifier that determines if there's a
|
||||
cache hit in the future and Superset can retrieve cached data.
|
||||
|
||||
**Custom URL Parameters**
|
||||
### Custom URL Parameters
|
||||
|
||||
The `{{ url_param('custom_variable') }}` macro lets you define arbitrary URL
|
||||
parameters and reference them in your SQL code.
|
||||
@@ -299,7 +299,7 @@ Here's a concrete example:
|
||||
WHERE country_code = 'US'
|
||||
```
|
||||
|
||||
**Explicitly Including Values in Cache Key**
|
||||
### Explicitly Including Values in Cache Key
|
||||
|
||||
The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the
|
||||
accumulated list of values used in the calculation of the cache key.
|
||||
@@ -311,7 +311,7 @@ in the cache key. You can gain more context
|
||||
Note that this function powers the caching of the `user_id` and `username` values
|
||||
in the `current_user_id()` and `current_username()` function calls (if you have caching enabled).
|
||||
|
||||
**Filter Values**
|
||||
### Filter Values
|
||||
|
||||
You can retrieve the value for a specific filter as a list using `{{ filter_values() }}`.
|
||||
|
||||
@@ -332,7 +332,7 @@ GROUP BY action
|
||||
|
||||
There `where_in` filter converts the list of values from `filter_values('action_type')` into a string suitable for an `IN` expression.
|
||||
|
||||
**Filters for a Specific Column**
|
||||
### Filters for a Specific Column
|
||||
|
||||
The `{{ get_filters() }}` macro returns the filters applied to a given column. In addition to
|
||||
returning the values (similar to how `filter_values()` does), the `get_filters()` macro
|
||||
@@ -394,7 +394,7 @@ Here's a concrete example:
|
||||
order by lineage, level
|
||||
```
|
||||
|
||||
**Time Filter**
|
||||
### Time Filter
|
||||
|
||||
The `{{ get_time_filter() }}` macro returns the time filter applied to a specific column. This is useful if you want
|
||||
to handle time filters inside the virtual dataset, as by default the time filter is placed on the outer query. This can
|
||||
@@ -469,7 +469,7 @@ WHERE
|
||||
AND dttm < {{ time_filter.to_expr }}
|
||||
```
|
||||
|
||||
**Datasets**
|
||||
### Datasets
|
||||
|
||||
It's possible to query physical and virtual datasets using the `dataset` macro. This is useful if you've defined computed columns and metrics on your datasets, and want to reuse the definition in adhoc SQL Lab queries.
|
||||
|
||||
@@ -493,7 +493,7 @@ Since metrics are aggregations, the resulting SQL expression will be grouped by
|
||||
SELECT * FROM {{ dataset(42, include_metrics=True, columns=["ds", "category"]) }} LIMIT 10
|
||||
```
|
||||
|
||||
**Metrics**
|
||||
### Metrics
|
||||
|
||||
The `{{ metric('metric_key', dataset_id) }}` macro can be used to retrieve the metric SQL syntax from a dataset. This can be useful for different purposes:
|
||||
|
||||
@@ -511,7 +511,7 @@ The parameter can be used in SQL Lab, or when fetching a metric from another dat
|
||||
|
||||
Superset supports [builtin filters from the Jinja2 templating package](https://jinja.palletsprojects.com/en/stable/templates/#builtin-filters). Custom filters have also been implemented:
|
||||
|
||||
**Where In**
|
||||
### Where In
|
||||
Parses a list into a SQL-compatible statement. This is useful with macros that return an array (for example the `filter_values` macro):
|
||||
|
||||
```
|
||||
@@ -528,7 +528,7 @@ Dashboard filter without any value applied
|
||||
{{ filter_values('column')|where_in(default_to_none=True) }} => None
|
||||
```
|
||||
|
||||
**To Datetime**
|
||||
### To Datetime
|
||||
|
||||
Loads a string as a `datetime` object. This is useful when performing date operations. For example:
|
||||
```
|
||||
|
||||
+9
-9
@@ -28,10 +28,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@docusaurus/core": "3.9.1",
|
||||
"@docusaurus/plugin-client-redirects": "3.9.1",
|
||||
"@docusaurus/preset-classic": "3.9.1",
|
||||
"@docusaurus/theme-mermaid": "^3.9.1",
|
||||
"@docusaurus/core": "3.9.2",
|
||||
"@docusaurus/plugin-client-redirects": "3.9.2",
|
||||
"@docusaurus/preset-classic": "3.9.2",
|
||||
"@docusaurus/theme-mermaid": "^3.9.2",
|
||||
"@emotion/core": "^10.0.27",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@emotion/styled": "^10.0.27",
|
||||
@@ -49,8 +49,8 @@
|
||||
"@storybook/preview-api": "^8.6.11",
|
||||
"@storybook/theming": "^8.6.11",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"antd": "^5.27.4",
|
||||
"caniuse-lite": "^1.0.30001750",
|
||||
"antd": "^5.27.5",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"json-bigint": "^1.0.0",
|
||||
"less": "^4.4.2",
|
||||
@@ -63,14 +63,14 @@
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.1.1",
|
||||
"storybook": "^8.6.11",
|
||||
"swagger-ui-react": "^5.29.4",
|
||||
"swagger-ui-react": "^5.29.5",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"ts-loader": "^9.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.9.1",
|
||||
"@docusaurus/tsconfig": "^3.9.1",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@docusaurus/tsconfig": "^3.9.2",
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.46.0",
|
||||
|
||||
+266
-262
@@ -1593,10 +1593,10 @@
|
||||
marked "^16.3.0"
|
||||
zod "^4.1.8"
|
||||
|
||||
"@docusaurus/babel@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.9.1.tgz#5297195ab34df9e184e3e2fe20de1a2e1b2a22e8"
|
||||
integrity sha512-/uoi3oG+wvbVWNBRfPrzrEslOSeLxrQEyWMywK51TLDFTANqIRivzkMusudh5bdDty8fXzCYUT+tg5t697jYqg==
|
||||
"@docusaurus/babel@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.9.2.tgz#f956c638baeccf2040e482c71a742bc7e35fdb22"
|
||||
integrity sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA==
|
||||
dependencies:
|
||||
"@babel/core" "^7.25.9"
|
||||
"@babel/generator" "^7.25.9"
|
||||
@@ -1608,23 +1608,23 @@
|
||||
"@babel/runtime" "^7.25.9"
|
||||
"@babel/runtime-corejs3" "^7.25.9"
|
||||
"@babel/traverse" "^7.25.9"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
babel-plugin-dynamic-import-node "^2.3.3"
|
||||
fs-extra "^11.1.1"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/bundler@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.9.1.tgz#6b78c152cf364d706249f6978f8e3fedf576b118"
|
||||
integrity sha512-E1c9DgNmAz4NqbNtiJVp4UgjLtr8O01IgtXD/NDQ4PZaK8895cMiTOgb3k7mN0qX8A3lb8vqyrPJ842+yMpuUg==
|
||||
"@docusaurus/bundler@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.9.2.tgz#0ca82cda4acf13a493e3f66061aea351e9d356cf"
|
||||
integrity sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA==
|
||||
dependencies:
|
||||
"@babel/core" "^7.25.9"
|
||||
"@docusaurus/babel" "3.9.1"
|
||||
"@docusaurus/cssnano-preset" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/babel" "3.9.2"
|
||||
"@docusaurus/cssnano-preset" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
babel-loader "^9.2.1"
|
||||
clean-css "^5.3.3"
|
||||
copy-webpack-plugin "^11.0.0"
|
||||
@@ -1644,18 +1644,18 @@
|
||||
webpack "^5.95.0"
|
||||
webpackbar "^6.0.1"
|
||||
|
||||
"@docusaurus/core@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.9.1.tgz#be4d859464fee8889794d8527f884e931d591f2e"
|
||||
integrity sha512-FWDk1LIGD5UR5Zmm9rCrXRoxZUgbwuP6FBA7rc50DVfzqDOMkeMe3NyJhOsA2dF0zBE3VbHEIMmTjKwTZJwbaA==
|
||||
"@docusaurus/core@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.9.2.tgz#cc970f29b85a8926d63c84f8cffdcda43ed266ff"
|
||||
integrity sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==
|
||||
dependencies:
|
||||
"@docusaurus/babel" "3.9.1"
|
||||
"@docusaurus/bundler" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/babel" "3.9.2"
|
||||
"@docusaurus/bundler" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
boxen "^6.2.1"
|
||||
chalk "^4.1.2"
|
||||
chokidar "^3.5.3"
|
||||
@@ -1692,32 +1692,32 @@
|
||||
webpack-dev-server "^5.2.2"
|
||||
webpack-merge "^6.0.1"
|
||||
|
||||
"@docusaurus/cssnano-preset@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.1.tgz#fa57c81a3f41e4d118115f86c85a71aed6b90f49"
|
||||
integrity sha512-2y7+s7RWQMqBg+9ejeKwvZs7Bdw/hHIVJIodwMXbs2kr+S48AhcmAfdOh6Cwm0unJb0hJUshN0ROwRoQMwl3xg==
|
||||
"@docusaurus/cssnano-preset@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.9.2.tgz#523aab65349db3c51a77f2489048d28527759428"
|
||||
integrity sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ==
|
||||
dependencies:
|
||||
cssnano-preset-advanced "^6.1.2"
|
||||
postcss "^8.5.4"
|
||||
postcss-sort-media-queries "^5.2.0"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/logger@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.9.1.tgz#0209c4c1044ee35d89dbf676e3cbb5dc8b59c82b"
|
||||
integrity sha512-C9iFzXwHzwvGlisE4bZx+XQE0JIqlGAYAd5LzpR7fEDgjctu7yL8bE5U4nTNywXKHURDzMt4RJK8V6+stFHVkA==
|
||||
"@docusaurus/logger@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.9.2.tgz#6ec6364b90f5a618a438cc9fd01ac7376869f92a"
|
||||
integrity sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA==
|
||||
dependencies:
|
||||
chalk "^4.1.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/mdx-loader@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.9.1.tgz#0ef77bee13450c83c18338f8e5f1753ed2e9ee3f"
|
||||
integrity sha512-/1PY8lqry8jCt0qZddJSpc0U2sH6XC27kVJZfpA7o2TiQ3mdBQyH5AVbj/B2m682B1ounE+XjI0LdpOkAQLPoA==
|
||||
"@docusaurus/mdx-loader@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.9.2.tgz#78d238de6c6203fa811cc2a7e90b9b79e111408c"
|
||||
integrity sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ==
|
||||
dependencies:
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@mdx-js/mdx" "^3.0.0"
|
||||
"@slorber/remark-comment" "^1.0.0"
|
||||
escape-html "^1.0.3"
|
||||
@@ -1740,12 +1740,12 @@
|
||||
vfile "^6.0.1"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/module-type-aliases@3.9.1", "@docusaurus/module-type-aliases@^3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.1.tgz#201959a22e7b30881cf879a21d2ae5b26415b705"
|
||||
integrity sha512-YBce3GbJGGcMbJTyHcnEOMvdXqg41pa5HsrMCGA5Rm4z0h0tHS6YtEldj0mlfQRhCG7Y0VD66t2tb87Aom+11g==
|
||||
"@docusaurus/module-type-aliases@3.9.2", "@docusaurus/module-type-aliases@^3.9.1":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.9.2.tgz#993c7cb0114363dea5ef6855e989b3ad4b843a34"
|
||||
integrity sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew==
|
||||
dependencies:
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
"@types/react-router-config" "*"
|
||||
@@ -1753,34 +1753,34 @@
|
||||
react-helmet-async "npm:@slorber/react-helmet-async@1.3.0"
|
||||
react-loadable "npm:@docusaurus/react-loadable@6.0.0"
|
||||
|
||||
"@docusaurus/plugin-client-redirects@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.9.1.tgz#06e07487f1596c62536d50afac81535d5fe60a20"
|
||||
integrity sha512-+1InCGvAnw46H+TnVqxaYlJC0qy9AY5gTMgTx2ZFryjAsImJNs3i1pEYW/iUTVbOdtWRj3E/87E4ehbBIaA1TA==
|
||||
"@docusaurus/plugin-client-redirects@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.9.2.tgz#9c27025c72aeeedeb783a94720163911567da0e8"
|
||||
integrity sha512-lUgMArI9vyOYMzLRBUILcg9vcPTCyyI2aiuXq/4npcMVqOr6GfmwtmBYWSbNMlIUM0147smm4WhpXD0KFboffw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
eta "^2.2.0"
|
||||
fs-extra "^11.1.1"
|
||||
lodash "^4.17.21"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-content-blog@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.1.tgz#bf6619847065360d52abc5bf1da307f5ce2a19f8"
|
||||
integrity sha512-vT6kIimpJLWvW9iuWzH4u7VpTdsGlmn4yfyhq0/Kb1h4kf9uVouGsTmrD7WgtYBUG1P+TSmQzUUQa+ALBSRTig==
|
||||
"@docusaurus/plugin-content-blog@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.9.2.tgz#d5ce51eb7757bdab0515e2dd26a793ed4e119df9"
|
||||
integrity sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
cheerio "1.0.0-rc.12"
|
||||
feed "^4.2.2"
|
||||
fs-extra "^11.1.1"
|
||||
@@ -1792,20 +1792,20 @@
|
||||
utility-types "^3.10.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/plugin-content-docs@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.1.tgz#e3e75d4aa310689c262c18e10010788e53f101ec"
|
||||
integrity sha512-DyLk9BIA6I9gPIuia8XIL+XIEbNnExam6AHzRsfrEq4zJr7k/DsWW7oi4aJMepDnL7jMRhpVcdsCxdjb0/A9xg==
|
||||
"@docusaurus/plugin-content-docs@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz#cd8f2d1c06e53c3fa3d24bdfcb48d237bf2d6b2e"
|
||||
integrity sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@types/react-router-config" "^5.0.7"
|
||||
combine-promises "^1.1.0"
|
||||
fs-extra "^11.1.1"
|
||||
@@ -1816,142 +1816,142 @@
|
||||
utility-types "^3.10.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/plugin-content-pages@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.1.tgz#044b8adbd2a673ff22630a74b3e0ce482761655d"
|
||||
integrity sha512-/1wFzRnXYASI+Nv9ck9IVPIMw0O5BGQ8ZVhDzEwhkL+tl44ycvSnY6PIe6rW2HLxsw61Z3WFwAiU8+xMMtMZpg==
|
||||
"@docusaurus/plugin-content-pages@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.9.2.tgz#22db6c88ade91cec0a9e87a00b8089898051b08d"
|
||||
integrity sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
fs-extra "^11.1.1"
|
||||
tslib "^2.6.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/plugin-css-cascade-layers@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.1.tgz#958a04679279e787d14fd3cc423ad35c580dc6fc"
|
||||
integrity sha512-/QyW2gRCk/XE3ttCK/ERIgle8KJ024dBNKMu6U5SmpJvuT2il1n5jR/48Pp/9wEwut8WVml4imNm6X8JsL5A0Q==
|
||||
"@docusaurus/plugin-css-cascade-layers@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.9.2.tgz#358c85f63f1c6a11f611f1b8889d9435c11b22f8"
|
||||
integrity sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-debug@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.9.1.tgz#5dbe01771176697f427b89a1ff023a3967c3e674"
|
||||
integrity sha512-qPeAuk0LccC251d7jg2MRhNI+o7niyqa924oEM/AxnZJvIpMa596aAxkRImiAqNN6+gtLE1Hkrz/RHUH2HDGsA==
|
||||
"@docusaurus/plugin-debug@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.9.2.tgz#b5df4db115583f5404a252dbf66f379ff933e53c"
|
||||
integrity sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
fs-extra "^11.1.1"
|
||||
react-json-view-lite "^2.3.0"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-google-analytics@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.1.tgz#226e39ed6d0a5eb3978dc5189bc9676235756446"
|
||||
integrity sha512-k4Qq2HphqOrIU/CevGPdEO1yJnWUI8m0zOJsYt5NfMJwNsIn/gDD6gv/DKD+hxHndQT5pacsfBd4BWHZVNVroQ==
|
||||
"@docusaurus/plugin-google-analytics@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.9.2.tgz#857fe075fdeccdf6959e62954d9efe39769fa247"
|
||||
integrity sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-google-gtag@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.1.tgz#971b075898d46d1a59482b2873ccb9aa2e679910"
|
||||
integrity sha512-n9BURBiQyJKI/Ecz35IUjXYwXcgNCSq7/eA07+ZYcDiSyH2p/EjPf8q/QcZG3CyEJPZ/SzGkDHePfcVPahY4Gg==
|
||||
"@docusaurus/plugin-google-gtag@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.9.2.tgz#df75b1a90ae9266b0471909ba0265f46d5dcae62"
|
||||
integrity sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@types/gtag.js" "^0.0.12"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-google-tag-manager@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.1.tgz#b26770d8bb07cedc1e01305cd9d66c2e4ce6d654"
|
||||
integrity sha512-rZAQZ25ZuXaThBajxzLjXieTDUCMmBzfAA6ThElQ3o7Q+LEpOjCIrwGFau0KLY9HeG6x91+FwwsAM8zeApYDrg==
|
||||
"@docusaurus/plugin-google-tag-manager@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.9.2.tgz#d1a3cf935acb7d31b84685e92d70a1d342946677"
|
||||
integrity sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-sitemap@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.1.tgz#e84717c1e52f3a61f9fea414ef98ebe025e7ffd2"
|
||||
integrity sha512-k/bf5cXDxAJUYTzqatgFJwmZsLUbIgl6S8AdZMKGG2Mv2wcOHt+EQNN9qPyWZ5/9cFj+Q8f8DN+KQheBMYLong==
|
||||
"@docusaurus/plugin-sitemap@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.9.2.tgz#e1d9f7012942562cc0c6543d3cb2cdc4ae713dc4"
|
||||
integrity sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
fs-extra "^11.1.1"
|
||||
sitemap "^7.1.1"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/plugin-svgr@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.1.tgz#394ad2b8da3af587a0f68167252b4bf99fb72351"
|
||||
integrity sha512-TeZOXT2PSdTNR1OpDJMkYqFyX7MMhbd4t16hQByXksgZQCXNyw3Dio+KaDJ2Nj+LA4WkOvsk45bWgYG5MAaXSQ==
|
||||
"@docusaurus/plugin-svgr@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.9.2.tgz#62857ed79d97c0150d25f7e7380fdee65671163a"
|
||||
integrity sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@svgr/core" "8.1.0"
|
||||
"@svgr/webpack" "^8.1.0"
|
||||
tslib "^2.6.0"
|
||||
webpack "^5.88.1"
|
||||
|
||||
"@docusaurus/preset-classic@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.9.1.tgz#58d86664b5c9779578092556a0e6ae5ccebbd6c0"
|
||||
integrity sha512-ZHga2xsxxsyd0dN1BpLj8S889Eu9eMBuj2suqxdw/vaaXu/FjJ8KEGbcaeo6nHPo8VQcBBnPEdkBtSDm2TfMNw==
|
||||
"@docusaurus/preset-classic@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.9.2.tgz#85cc4f91baf177f8146c9ce896dfa1f0fd377050"
|
||||
integrity sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/plugin-content-blog" "3.9.1"
|
||||
"@docusaurus/plugin-content-docs" "3.9.1"
|
||||
"@docusaurus/plugin-content-pages" "3.9.1"
|
||||
"@docusaurus/plugin-css-cascade-layers" "3.9.1"
|
||||
"@docusaurus/plugin-debug" "3.9.1"
|
||||
"@docusaurus/plugin-google-analytics" "3.9.1"
|
||||
"@docusaurus/plugin-google-gtag" "3.9.1"
|
||||
"@docusaurus/plugin-google-tag-manager" "3.9.1"
|
||||
"@docusaurus/plugin-sitemap" "3.9.1"
|
||||
"@docusaurus/plugin-svgr" "3.9.1"
|
||||
"@docusaurus/theme-classic" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/theme-search-algolia" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/plugin-content-blog" "3.9.2"
|
||||
"@docusaurus/plugin-content-docs" "3.9.2"
|
||||
"@docusaurus/plugin-content-pages" "3.9.2"
|
||||
"@docusaurus/plugin-css-cascade-layers" "3.9.2"
|
||||
"@docusaurus/plugin-debug" "3.9.2"
|
||||
"@docusaurus/plugin-google-analytics" "3.9.2"
|
||||
"@docusaurus/plugin-google-gtag" "3.9.2"
|
||||
"@docusaurus/plugin-google-tag-manager" "3.9.2"
|
||||
"@docusaurus/plugin-sitemap" "3.9.2"
|
||||
"@docusaurus/plugin-svgr" "3.9.2"
|
||||
"@docusaurus/theme-classic" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/theme-search-algolia" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
|
||||
"@docusaurus/theme-classic@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.9.1.tgz#790fb1b8058d0572632211023ead238c1a6450e0"
|
||||
integrity sha512-LrAIu/mQ04nG6s1cssC0TMmICD8twFIIn/hJ5Pd9uIPQvtKnyAKEn12RefopAul5KfMo9kixPaqogV5jIJr26w==
|
||||
"@docusaurus/theme-classic@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.9.2.tgz#6e514f99a0ff42b80afcf42d5e5d042618311ce0"
|
||||
integrity sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/plugin-content-blog" "3.9.1"
|
||||
"@docusaurus/plugin-content-docs" "3.9.1"
|
||||
"@docusaurus/plugin-content-pages" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/theme-translations" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/plugin-content-blog" "3.9.2"
|
||||
"@docusaurus/plugin-content-docs" "3.9.2"
|
||||
"@docusaurus/plugin-content-pages" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/theme-translations" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
clsx "^2.0.0"
|
||||
infima "0.2.0-alpha.45"
|
||||
@@ -1965,15 +1965,15 @@
|
||||
tslib "^2.6.0"
|
||||
utility-types "^3.10.0"
|
||||
|
||||
"@docusaurus/theme-common@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.9.1.tgz#095cbeab489d51380143951508571888f4d2928d"
|
||||
integrity sha512-j9adi961F+6Ps9d0jcb5BokMcbjXAAJqKkV43eo8nh4YgmDj7KUNDX4EnOh/MjTQeO06oPY5cxp3yUXdW/8Ggw==
|
||||
"@docusaurus/theme-common@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.9.2.tgz#487172c6fef9815c2746ef62a71e4f5b326f9ba5"
|
||||
integrity sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==
|
||||
dependencies:
|
||||
"@docusaurus/mdx-loader" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/mdx-loader" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
"@types/history" "^4.7.11"
|
||||
"@types/react" "*"
|
||||
"@types/react-router-config" "*"
|
||||
@@ -1983,32 +1983,32 @@
|
||||
tslib "^2.6.0"
|
||||
utility-types "^3.10.0"
|
||||
|
||||
"@docusaurus/theme-mermaid@^3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.1.tgz#92de20580489e05b3da6716503fda17e5a337a4d"
|
||||
integrity sha512-aKMFlQfxueVBPdCdrNSshG12fOkJXSn1sb6EhI/sGn3UpiTEiazJm4QLP6NoF78mqq8O5Ar2Yll+iHWLvCsuZQ==
|
||||
"@docusaurus/theme-mermaid@^3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.9.2.tgz#f065e4b4b319560ddd8c3be65ce9dd19ce1d5cc8"
|
||||
integrity sha512-5vhShRDq/ntLzdInsQkTdoKWSzw8d1jB17sNPYhA/KvYYFXfuVEGHLM6nrf8MFbV8TruAHDG21Fn3W4lO8GaDw==
|
||||
dependencies:
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/module-type-aliases" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/module-type-aliases" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
mermaid ">=11.6.0"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/theme-search-algolia@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.1.tgz#2f2ad5212201a1bed3acf8527ae6d81a079e654e"
|
||||
integrity sha512-WjM28bzlgfT6nHlEJemkwyGVpvGsZWPireV/w+wZ1Uo64xCZ8lNOb4xwQRukDaLSed3oPBN0gSnu06l5VuCXHg==
|
||||
"@docusaurus/theme-search-algolia@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.9.2.tgz#420fd5b27fc1673b48151fdc9fe7167ba135ed50"
|
||||
integrity sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw==
|
||||
dependencies:
|
||||
"@docsearch/react" "^3.9.0 || ^4.1.0"
|
||||
"@docusaurus/core" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/plugin-content-docs" "3.9.1"
|
||||
"@docusaurus/theme-common" "3.9.1"
|
||||
"@docusaurus/theme-translations" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-validation" "3.9.1"
|
||||
"@docusaurus/core" "3.9.2"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/plugin-content-docs" "3.9.2"
|
||||
"@docusaurus/theme-common" "3.9.2"
|
||||
"@docusaurus/theme-translations" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-validation" "3.9.2"
|
||||
algoliasearch "^5.37.0"
|
||||
algoliasearch-helper "^3.26.0"
|
||||
clsx "^2.0.0"
|
||||
@@ -2018,23 +2018,23 @@
|
||||
tslib "^2.6.0"
|
||||
utility-types "^3.10.0"
|
||||
|
||||
"@docusaurus/theme-translations@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.9.1.tgz#189f1942d0178bc0da659db88c07682c7d7191ee"
|
||||
integrity sha512-mUQd49BSGKTiM6vP9+JFgRJL28lMIN3PUvXjF3rzuOHMByUZUBNwCt26Z23GkKiSIOrRkjKoaBNTipR/MHdYSQ==
|
||||
"@docusaurus/theme-translations@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.9.2.tgz#238cd69c2da92d612be3d3b4f95944c1d0f1e041"
|
||||
integrity sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA==
|
||||
dependencies:
|
||||
fs-extra "^11.1.1"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/tsconfig@^3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.9.1.tgz#a39cb74021f16dd4794db9182de812303817528e"
|
||||
integrity sha512-stdzM1dNDgRO0OvxeznXlE3N1igUoeHPNJjiKqyffLizgpVgNXJBAWeG6fuoYiCH4udGUBqy2dyM+1+kG2/UPQ==
|
||||
"@docusaurus/tsconfig@^3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/tsconfig/-/tsconfig-3.9.2.tgz#7f440e0ae665b841e1d487749037f26a0275f9c1"
|
||||
integrity sha512-j6/Fp4Rlpxsc632cnRnl5HpOWeb6ZKssDj6/XzzAzVGXXfm9Eptx3rxCC+fDzySn9fHTS+CWJjPineCR1bB5WQ==
|
||||
|
||||
"@docusaurus/types@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.9.1.tgz#e4fdaf0b91ea014a6aae0d8b62d59f3f020117b6"
|
||||
integrity sha512-ElekJ29sk39s5LTEZMByY1c2oH9FMtw7KbWFU3BtuQ1TytfIK39HhUivDEJvm5KCLyEnnfUZlvSNDXeyk0vzAA==
|
||||
"@docusaurus/types@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.9.2.tgz#e482cf18faea0d1fa5ce0e3f1e28e0f32d2593eb"
|
||||
integrity sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q==
|
||||
dependencies:
|
||||
"@mdx-js/mdx" "^3.0.0"
|
||||
"@types/history" "^4.7.11"
|
||||
@@ -2047,36 +2047,36 @@
|
||||
webpack "^5.95.0"
|
||||
webpack-merge "^5.9.0"
|
||||
|
||||
"@docusaurus/utils-common@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.9.1.tgz#202778391caed923c2527a166a3aae3a22b2dcad"
|
||||
integrity sha512-4M1u5Q8Zn2CYL2TJ864M51FV4YlxyGyfC3x+7CLuR6xsyTVNBNU4QMcPgsTHRS9J2+X6Lq7MyH6hiWXyi/sXUQ==
|
||||
"@docusaurus/utils-common@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.9.2.tgz#e89bfcf43d66359f43df45293fcdf22814847460"
|
||||
integrity sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw==
|
||||
dependencies:
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/utils-validation@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.9.1.tgz#8f9816b31ffb647539881f3c153d46f54e6399f7"
|
||||
integrity sha512-5bzab5si3E1udrlZuVGR17857Lfwe8iFPoy5AvMP9PXqDfoyIKT7gDQgAmxdRDMurgHaJlyhXEHHdzDKkOxxZQ==
|
||||
"@docusaurus/utils-validation@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.9.2.tgz#04aec285604790806e2fc5aa90aa950dc7ba75ae"
|
||||
integrity sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==
|
||||
dependencies:
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/utils" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/utils" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
fs-extra "^11.2.0"
|
||||
joi "^17.9.2"
|
||||
js-yaml "^4.1.0"
|
||||
lodash "^4.17.21"
|
||||
tslib "^2.6.0"
|
||||
|
||||
"@docusaurus/utils@3.9.1":
|
||||
version "3.9.1"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.9.1.tgz#9b78849a2be5e3023580b800409aae36a0da6dc8"
|
||||
integrity sha512-YAL4yhhWLl9DXuf5MVig260a6INz4MehrBGFU/CZu8yXmRiYEuQvRFWh9ZsjfAOyaG7za1MNmBVZ4VVAi/CiJA==
|
||||
"@docusaurus/utils@3.9.2":
|
||||
version "3.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.9.2.tgz#ffab7922631c7e0febcb54e6d499f648bf8a89eb"
|
||||
integrity sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==
|
||||
dependencies:
|
||||
"@docusaurus/logger" "3.9.1"
|
||||
"@docusaurus/types" "3.9.1"
|
||||
"@docusaurus/utils-common" "3.9.1"
|
||||
"@docusaurus/logger" "3.9.2"
|
||||
"@docusaurus/types" "3.9.2"
|
||||
"@docusaurus/utils-common" "3.9.2"
|
||||
escape-string-regexp "^4.0.0"
|
||||
execa "5.1.1"
|
||||
file-loader "^6.2.0"
|
||||
@@ -2466,11 +2466,16 @@
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.37.0", "@eslint/js@^9.37.0":
|
||||
"@eslint/js@9.37.0":
|
||||
version "9.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.37.0.tgz#0cfd5aa763fe5d1ee60bedf84cd14f54bcf9e21b"
|
||||
integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==
|
||||
|
||||
"@eslint/js@^9.38.0":
|
||||
version "9.38.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.38.0.tgz#f7aa9c7577577f53302c1d795643589d7709ebd1"
|
||||
integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==
|
||||
|
||||
"@eslint/object-schema@^2.1.6":
|
||||
version "2.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
|
||||
@@ -2796,14 +2801,13 @@
|
||||
classnames "^2.3.2"
|
||||
rc-util "^5.24.4"
|
||||
|
||||
"@rc-component/qrcode@~1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/qrcode/-/qrcode-1.0.0.tgz#48a8de5eb11d0e65926f1377c4b1ef4c888997f5"
|
||||
integrity sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==
|
||||
"@rc-component/qrcode@~1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/qrcode/-/qrcode-1.0.1.tgz#98e0a79dc95f26fe211b59d04ef3312bc70dedbe"
|
||||
integrity sha512-g8eeeaMyFXVlq8cZUeaxCDhfIYjpao0l9cvm5gFwKXy/Vm1yDWV7h2sjH5jHYzdFedlVKBpATFB1VKMrHzwaWQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.24.7"
|
||||
classnames "^2.3.2"
|
||||
rc-util "^5.38.0"
|
||||
|
||||
"@rc-component/tour@~1.15.1":
|
||||
version "1.15.1"
|
||||
@@ -4746,10 +4750,10 @@ ansi-styles@^6.1.0:
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
|
||||
antd@^5.27.4:
|
||||
version "5.27.4"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.4.tgz#13c97deb12e6aeb43adecd23f3dbe3139a62e579"
|
||||
integrity sha512-rhArohoAUCxhkPjGI/BXthOrrjaElL4Fb7d4vEHnIR3DpxFXfegd4rN21IgGdiF+Iz4EFuUZu8MdS8NuJHLSVQ==
|
||||
antd@^5.27.5:
|
||||
version "5.27.5"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.27.5.tgz#978b265c722b9229e7dcc2fcddc5f5445af9bdf0"
|
||||
integrity sha512-Ehd9mqtHvJ1clon1yJ/1BTV6eX/3SH2YXZZPTHUk8XdzXFwUioI+Lht47s+MaHIUBY77RnZrmtKwwR+VVu0l7A==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^7.2.1"
|
||||
"@ant-design/cssinjs" "^1.23.0"
|
||||
@@ -4760,7 +4764,7 @@ antd@^5.27.4:
|
||||
"@babel/runtime" "^7.26.0"
|
||||
"@rc-component/color-picker" "~2.0.1"
|
||||
"@rc-component/mutate-observer" "^1.1.0"
|
||||
"@rc-component/qrcode" "~1.0.0"
|
||||
"@rc-component/qrcode" "~1.0.1"
|
||||
"@rc-component/tour" "~1.15.1"
|
||||
"@rc-component/trigger" "^2.3.0"
|
||||
classnames "^2.5.1"
|
||||
@@ -4790,7 +4794,7 @@ antd@^5.27.4:
|
||||
rc-slider "~11.1.9"
|
||||
rc-steps "~6.0.1"
|
||||
rc-switch "~4.1.0"
|
||||
rc-table "~7.53.0"
|
||||
rc-table "~7.54.0"
|
||||
rc-tabs "~15.7.0"
|
||||
rc-textarea "~1.10.2"
|
||||
rc-tooltip "~6.4.0"
|
||||
@@ -5305,10 +5309,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001750:
|
||||
version "1.0.30001750"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz#c229f82930033abd1502c6f73035356cf528bfbc"
|
||||
integrity sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001751:
|
||||
version "1.0.30001751"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz#dacd5d9f4baeea841641640139d2b2a4df4226ad"
|
||||
integrity sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -11729,10 +11733,10 @@ rc-switch@~4.1.0:
|
||||
classnames "^2.2.1"
|
||||
rc-util "^5.30.0"
|
||||
|
||||
rc-table@~7.53.0:
|
||||
version "7.53.1"
|
||||
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.53.1.tgz#b891aa39e9d1d944711f018692d2c52013afc90f"
|
||||
integrity sha512-firAd7Z+liqIDS5TubJ1qqcoBd6YcANLKWQDZhFf3rfoOTt/UNPj4n3O+2vhl+z4QMqwPEUVAil661WHA8H8Aw==
|
||||
rc-table@~7.54.0:
|
||||
version "7.54.0"
|
||||
resolved "https://registry.yarnpkg.com/rc-table/-/rc-table-7.54.0.tgz#dedd4ea18d1189f2acdf90a80f04d8ca0111e16a"
|
||||
integrity sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.1"
|
||||
"@rc-component/context" "^1.4.0"
|
||||
@@ -13259,10 +13263,10 @@ swagger-client@^3.35.7:
|
||||
ramda "^0.30.1"
|
||||
ramda-adjunct "^5.1.0"
|
||||
|
||||
swagger-ui-react@^5.29.4:
|
||||
version "5.29.4"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.4.tgz#ff061f301b46849a93c53b2490f7cebbea401832"
|
||||
integrity sha512-lBBRq75dHWnuN1uuxGOvJkoYr8F+AuZpOSUdHez9st7GlHKTPiBz5bOFONXPzbLKDWrwsPTQ/zArBSDjfqtVow==
|
||||
swagger-ui-react@^5.29.5:
|
||||
version "5.29.5"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.5.tgz#8c6eafebb75972c15a9f3e24627caec10cc32cbe"
|
||||
integrity sha512-D0YbsDhi4F38HsY5p1DjzuNduU/fVQxtqm3v0o2dRTF5BbLJYRSgjMZ79jejG4q3nNw4kuouCKKiq5xqCLjWrQ==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+138
-97
@@ -62,14 +62,14 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^2.1.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"dom-to-image-more": "^3.6.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"geolib": "^2.0.24",
|
||||
"geostyler": "^14.1.3",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
"react-loadable": "^5.5.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-reverse-portal": "^2.1.2",
|
||||
"react-reverse-portal": "^2.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-search-input": "^0.11.3",
|
||||
"react-sortable-hoc": "^2.0.0",
|
||||
@@ -139,10 +139,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@applitools/eyes-storybook": "^3.60.0",
|
||||
"@babel/cli": "^7.27.2",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/compat-data": "^7.28.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/eslint-parser": "^7.25.9",
|
||||
"@babel/eslint-parser": "^7.28.4",
|
||||
"@babel/node": "^7.22.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
@@ -161,7 +161,7 @@
|
||||
"@hot-loader/react-dom": "^17.0.2",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
"@storybook/addon-essentials": "8.1.11",
|
||||
@@ -184,7 +184,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-json-tree": "^0.13.0",
|
||||
@@ -210,7 +210,7 @@
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-typescript-to-proptypes": "^2.0.0",
|
||||
"cheerio": "1.1.0",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"cross-env": "^10.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.2",
|
||||
@@ -228,7 +228,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-no-only-tests": "^3.3.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-prefer-function-component": "^3.3.0",
|
||||
@@ -268,7 +268,7 @@
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "5.4.5",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"webpack": "^5.102.0",
|
||||
"webpack": "^5.102.1",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
@@ -1106,13 +1106,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/cli": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.27.2.tgz",
|
||||
"integrity": "sha512-cfd7DnGlhH6OIyuPSSj3vcfIdnbXukhAyKY8NaZrFadC7pXyL9mOL5WgjcptiEJLi5k3j8aYvLIVCzezrWTaiA==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.3.tgz",
|
||||
"integrity": "sha512-n1RU5vuCX0CsaqaXm9I0KUCNKNQMy5epmzl/xdSSm70bSqhg9GWhgeosypyQLc0bK24+Xpk1WGzZlI9pJtkZdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"commander": "^6.2.0",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"fs-readdir-recursive": "^1.1.0",
|
||||
@@ -1201,9 +1201,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/eslint-parser": {
|
||||
"version": "7.26.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.26.5.tgz",
|
||||
"integrity": "sha512-Kkm8C8uxI842AwQADxl0GbcG1rupELYLShazYEZO/2DYjhyWXJIOUVOE3tBYm6JXzUCNJOZEzqc4rCW/jsEQYQ==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.28.4.tgz",
|
||||
"integrity": "sha512-Aa+yDiH87980jR6zvRfFuCR1+dLb00vBydhTL+zI992Rz/wQhSvuxjmOOuJOgO3XmakO6RykRGD2S1mq1AtgHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4125,6 +4125,12 @@
|
||||
"mjolnir.js": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deck.gl/core/node_modules/@luma.gl/constants": {
|
||||
"version": "9.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.10.tgz",
|
||||
"integrity": "sha512-O4Nx8UbWmrHHZ7ihKB8WiscX1cz05l1KvKorYTgq+xeXwz2Beh3MkXBMnA46uuyEtimN945OEdYshZnbh80jyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@deck.gl/extensions": {
|
||||
"version": "9.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.1.13.tgz",
|
||||
@@ -9000,9 +9006,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@luma.gl/constants": {
|
||||
"version": "9.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.9.tgz",
|
||||
"integrity": "sha512-yc9fml04OeTTcwK+7gmDMxoLQ67j4ZiAFXjmYvPomYyBVzS0NZxTDuwcCBmnxjLOiroOZW8FRRrVc/yOiFug2w==",
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.2.tgz",
|
||||
"integrity": "sha512-XURMF0gSh0ImZltYa/PCe9KgmopQJiOA6y1m1PxDxJY8OCLma7ZJyvomLn7TQBvPtWTYZsibTW7blu7RwThsaQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@luma.gl/core": {
|
||||
@@ -9078,6 +9084,12 @@
|
||||
"@luma.gl/core": "^9.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@luma.gl/webgl/node_modules/@luma.gl/constants": {
|
||||
"version": "9.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.9.tgz",
|
||||
"integrity": "sha512-yc9fml04OeTTcwK+7gmDMxoLQ67j4ZiAFXjmYvPomYyBVzS0NZxTDuwcCBmnxjLOiroOZW8FRRrVc/yOiFug2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/extent": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/extent/-/extent-0.4.0.tgz",
|
||||
@@ -10590,13 +10602,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
|
||||
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
|
||||
"version": "1.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
|
||||
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.55.0"
|
||||
"playwright": "1.56.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -16371,12 +16383,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz",
|
||||
"integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==",
|
||||
"version": "24.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
|
||||
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.13.0"
|
||||
"undici-types": "~7.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-forge": {
|
||||
@@ -20893,9 +20905,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
||||
"integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==",
|
||||
"version": "4.26.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
|
||||
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -20913,9 +20925,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
"electron-to-chromium": "^1.5.218",
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
"electron-to-chromium": "^1.5.227",
|
||||
"node-releases": "^2.0.21",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
},
|
||||
@@ -22723,9 +22735,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/copy-webpack-plugin": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.0.tgz",
|
||||
"integrity": "sha512-FgR/h5a6hzJqATDGd9YG41SeDViH+0bkHn6WNXCi5zKAZkeESeSxLySSsFLHqLEVCh0E+rITmCf0dusXWYukeQ==",
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz",
|
||||
"integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -23925,9 +23937,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||
"version": "1.11.18",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
||||
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
@@ -26343,14 +26355,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz",
|
||||
"integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==",
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
|
||||
"integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.0",
|
||||
"synckit": "^0.9.1"
|
||||
"synckit": "^0.11.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
@@ -26361,7 +26373,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/eslint": ">=8.0.0",
|
||||
"eslint": ">=8.0.0",
|
||||
"eslint-config-prettier": "*",
|
||||
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
|
||||
"prettier": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -26373,6 +26385,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier/node_modules/@pkgr/core": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
|
||||
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier/node_modules/synckit": {
|
||||
"version": "0.11.11",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
|
||||
"integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pkgr/core": "^0.2.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/synckit"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react": {
|
||||
"version": "7.37.5",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
|
||||
@@ -28414,9 +28455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
|
||||
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -46079,13 +46120,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
|
||||
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
|
||||
"version": "1.56.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
|
||||
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.0"
|
||||
"playwright-core": "1.56.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -46098,9 +46139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
|
||||
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
|
||||
"version": "1.56.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
|
||||
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -48969,13 +49010,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-reverse-portal": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.1.2.tgz",
|
||||
"integrity": "sha512-li4puNtBmMMJhtI+IVxeSX0RvK1ft8qjPSbCih4OKQ/YUIcROc31Nmo22gv94hTx8EUfR7fzZY47RuZF2YRMdQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reverse-portal/-/react-reverse-portal-2.3.0.tgz",
|
||||
"integrity": "sha512-kvbPfLPKg6Y3S6tVq83us2RghvDpOS4GcJxbI7cZ0V0tuzUaSzblRIhVnKLOucfqF4lN/i9oWvEmpEi6bAOYlQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
@@ -51689,9 +51730,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz",
|
||||
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -54372,9 +54413,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz",
|
||||
"integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -56413,9 +56454,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz",
|
||||
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@@ -57554,9 +57595,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.102.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.0.tgz",
|
||||
"integrity": "sha512-hUtqAR3ZLVEYDEABdBioQCIqSoguHbFn1K7WlPPWSuXmx0031BD73PSE35jKyftdSh4YLDoQNgK4pqBt5Q82MA==",
|
||||
"version": "5.102.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
|
||||
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -57568,7 +57609,7 @@
|
||||
"@webassemblyjs/wasm-parser": "^1.14.1",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-phases": "^1.0.3",
|
||||
"browserslist": "^4.24.5",
|
||||
"browserslist": "^4.26.3",
|
||||
"chrome-trace-event": "^1.0.2",
|
||||
"enhanced-resolve": "^5.17.3",
|
||||
"es-module-lexer": "^1.2.1",
|
||||
@@ -57580,8 +57621,8 @@
|
||||
"loader-runner": "^4.2.0",
|
||||
"mime-types": "^2.1.27",
|
||||
"neo-async": "^2.6.2",
|
||||
"schema-utils": "^4.3.2",
|
||||
"tapable": "^2.2.3",
|
||||
"schema-utils": "^4.3.3",
|
||||
"tapable": "^2.3.0",
|
||||
"terser-webpack-plugin": "^5.3.11",
|
||||
"watchpack": "^2.4.4",
|
||||
"webpack-sources": "^3.3.3"
|
||||
@@ -59296,7 +59337,7 @@
|
||||
"version": "0.20.3",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"yeoman-generator": "^7.5.1",
|
||||
"yosay": "^3.0.0"
|
||||
@@ -59977,9 +60018,9 @@
|
||||
}
|
||||
},
|
||||
"packages/generator-superset/node_modules/chalk": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
@@ -61343,7 +61384,7 @@
|
||||
"version": "0.0.1-rc5",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
@@ -64067,7 +64108,7 @@
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@apache-superset/core": "*",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@fontsource/fira-code": "^5.2.6",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
@@ -64083,7 +64124,7 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"dompurify": "^3.2.4",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
@@ -64107,7 +64148,7 @@
|
||||
"reselect": "^5.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"xss": "^1.0.14"
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/styled": "^11.14.1",
|
||||
@@ -64119,7 +64160,7 @@
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
@@ -65950,7 +65991,7 @@
|
||||
"@deck.gl/geo-layers": "^9.1.13",
|
||||
"@deck.gl/layers": "^9.1.13",
|
||||
"@deck.gl/react": "^9.1.14",
|
||||
"@luma.gl/constants": "^9.1.9",
|
||||
"@luma.gl/constants": "^9.2.2",
|
||||
"@luma.gl/core": "^9.1.9",
|
||||
"@luma.gl/engine": "^9.1.9",
|
||||
"@luma.gl/shadertools": "^9.1.9",
|
||||
@@ -65964,7 +66005,7 @@
|
||||
"d3-array": "^1.2.4",
|
||||
"d3-color": "^1.4.1",
|
||||
"d3-scale": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -66008,6 +66049,12 @@
|
||||
"@luma.gl/engine": "~9.1.9"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-deckgl/node_modules/@deck.gl/aggregation-layers/node_modules/@luma.gl/constants": {
|
||||
"version": "9.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.1.10.tgz",
|
||||
"integrity": "sha512-O4Nx8UbWmrHHZ7ihKB8WiscX1cz05l1KvKorYTgq+xeXwz2Beh3MkXBMnA46uuyEtimN945OEdYshZnbh80jyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"plugins/legacy-preset-chart-deckgl/node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||
@@ -66098,7 +66145,7 @@
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"dompurify": "^3.2.7",
|
||||
"dompurify": "^3.3.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
@@ -66111,16 +66158,10 @@
|
||||
"react": "^17.0.2"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dayjs": {
|
||||
"version": "1.11.18",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
||||
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
@@ -66192,7 +66233,7 @@
|
||||
"dependencies": {
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"d3-array": "^1.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -135,14 +135,14 @@
|
||||
"content-disposition": "^0.5.4",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^2.1.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"dom-to-image-more": "^3.6.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"geolib": "^2.0.24",
|
||||
"geostyler": "^14.1.3",
|
||||
"geostyler-data": "^1.1.0",
|
||||
@@ -185,7 +185,7 @@
|
||||
"react-loadable": "^5.5.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-reverse-portal": "^2.1.2",
|
||||
"react-reverse-portal": "^2.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-search-input": "^0.11.3",
|
||||
"react-sortable-hoc": "^2.0.0",
|
||||
@@ -212,10 +212,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@applitools/eyes-storybook": "^3.60.0",
|
||||
"@babel/cli": "^7.27.2",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/compat-data": "^7.28.0",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/eslint-parser": "^7.25.9",
|
||||
"@babel/eslint-parser": "^7.28.4",
|
||||
"@babel/node": "^7.22.6",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
@@ -234,7 +234,7 @@
|
||||
"@hot-loader/react-dom": "^17.0.2",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@mihkeleidast/storybook-addon-source": "^1.0.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@playwright/test": "^1.56.0",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-controls": "8.1.11",
|
||||
"@storybook/addon-essentials": "8.1.11",
|
||||
@@ -257,7 +257,7 @@
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/react": "^17.0.83",
|
||||
"@types/react-dom": "^17.0.26",
|
||||
"@types/react-json-tree": "^0.13.0",
|
||||
@@ -283,7 +283,7 @@
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-typescript-to-proptypes": "^2.0.0",
|
||||
"cheerio": "1.1.0",
|
||||
"copy-webpack-plugin": "^13.0.0",
|
||||
"copy-webpack-plugin": "^13.0.1",
|
||||
"cross-env": "^10.0.0",
|
||||
"css-loader": "^7.1.2",
|
||||
"css-minimizer-webpack-plugin": "^7.0.2",
|
||||
@@ -301,7 +301,7 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-no-only-tests": "^3.3.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-prefer-function-component": "^3.3.0",
|
||||
@@ -341,7 +341,7 @@
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "5.4.5",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"webpack": "^5.102.0",
|
||||
"webpack": "^5.102.1",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"yeoman-generator": "^7.5.1",
|
||||
"yosay": "^3.0.0"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/core": "^7.28.3",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"@fontsource/fira-code": "^5.2.6",
|
||||
"@fontsource/fira-code": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"ace-builds": "^1.43.3",
|
||||
@@ -38,7 +38,7 @@
|
||||
"csstype": "^3.1.3",
|
||||
"core-js": "^3.38.1",
|
||||
"d3-format": "^1.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
@@ -67,7 +67,7 @@
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
"xss": "^1.0.14"
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emotion/styled": "^11.14.1",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@deck.gl/geo-layers": "^9.1.13",
|
||||
"@deck.gl/layers": "^9.1.13",
|
||||
"@deck.gl/react": "^9.1.14",
|
||||
"@luma.gl/constants": "^9.1.9",
|
||||
"@luma.gl/constants": "^9.2.2",
|
||||
"@luma.gl/core": "^9.1.9",
|
||||
"@luma.gl/engine": "^9.1.9",
|
||||
"@luma.gl/shadertools": "^9.1.9",
|
||||
@@ -43,7 +43,7 @@
|
||||
"d3-array": "^1.2.4",
|
||||
"d3-color": "^1.4.1",
|
||||
"d3-scale": "^3.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
|
||||
+11
-12
@@ -16,10 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useState, memo } from 'react';
|
||||
import { styled, t } from '@superset-ui/core';
|
||||
import { useEffect, useState, memo, useMemo } from 'react';
|
||||
import { styled, t, sanitizeHtml } from '@superset-ui/core';
|
||||
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
|
||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||
import Handlebars from 'handlebars';
|
||||
import { isPlainObject } from 'lodash';
|
||||
|
||||
@@ -45,8 +44,6 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
|
||||
appContainer?.getAttribute('data-bootstrap') || '{}',
|
||||
);
|
||||
const htmlSanitization = common?.conf?.HTML_SANITIZATION ?? true;
|
||||
const htmlSchemaOverrides =
|
||||
common?.conf?.HTML_SANITIZATION_SCHEMA_EXTENSIONS || {};
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -60,6 +57,12 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
|
||||
}
|
||||
}, [templateSource, data]);
|
||||
|
||||
const htmlContent = useMemo(
|
||||
() =>
|
||||
htmlSanitization ? sanitizeHtml(renderedTemplate) : renderedTemplate,
|
||||
[renderedTemplate, htmlSanitization],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <ErrorContainer>{error}</ErrorContainer>;
|
||||
}
|
||||
@@ -73,13 +76,9 @@ export const HandlebarsRenderer: React.FC<HandlebarsRendererProps> = memo(
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
}}
|
||||
>
|
||||
<SafeMarkdown
|
||||
source={renderedTemplate || ''}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
</div>
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"lodash": "^4.17.21",
|
||||
"dayjs": "^1.11.18",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.2.7",
|
||||
"dompurify": "^3.3.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"urijs": "^1.19.11"
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"d3-array": "^1.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -142,7 +142,7 @@ const config: ControlPanelConfig = {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('X AXIS TITLE MARGIN'),
|
||||
label: t('X axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[1],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
@@ -214,7 +214,7 @@ const config: ControlPanelConfig = {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('Y AXIS TITLE MARGIN'),
|
||||
label: t('Y axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[1],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
|
||||
+3
-3
@@ -81,7 +81,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('AXIS TITLE MARGIN'),
|
||||
label: t('Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[0],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
@@ -114,7 +114,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: true,
|
||||
label: t('AXIS TITLE MARGIN'),
|
||||
label: t('Axis title margin'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_MARGIN_OPTIONS[1],
|
||||
choices: formatSelectOptions(sections.TITLE_MARGIN_OPTIONS),
|
||||
@@ -132,7 +132,7 @@ function createAxisTitleControl(axis: 'x' | 'y'): ControlSetRow[] {
|
||||
type: 'SelectControl',
|
||||
freeForm: true,
|
||||
clearable: false,
|
||||
label: t('AXIS TITLE POSITION'),
|
||||
label: t('Axis title position'),
|
||||
renderTrigger: true,
|
||||
default: sections.TITLE_POSITION_OPTIONS[0][0],
|
||||
choices: sections.TITLE_POSITION_OPTIONS,
|
||||
|
||||
+10
-4
@@ -1019,10 +1019,16 @@ class DatasourceEditor extends PureComponent {
|
||||
<Field
|
||||
fieldKey="default_endpoint"
|
||||
label={t('Default URL')}
|
||||
description={t(
|
||||
`Default URL to redirect to when accessing from the dataset list page.
|
||||
Accepts relative URLs such as <span style=„white-space: nowrap;”>/superset/dashboard/{id}/</span>`,
|
||||
)}
|
||||
description={
|
||||
<>
|
||||
{t(
|
||||
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
|
||||
)}{' '}
|
||||
<Typography.Text code>
|
||||
/superset/dashboard/{'{id}'}/
|
||||
</Typography.Text>
|
||||
</>
|
||||
}
|
||||
control={<TextControl controlId="default_endpoint" />}
|
||||
/>
|
||||
<Field
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from 'src/SqlLab/actions/sqlLab';
|
||||
import { RootState, store } from 'src/views/store';
|
||||
import { AnyListenerPredicate } from '@reduxjs/toolkit';
|
||||
import memoizeOne from 'memoize-one';
|
||||
import type { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { Disposable } from '../models';
|
||||
import { createActionListener } from '../utils';
|
||||
@@ -198,13 +197,10 @@ const getActiveEditorImmutableId = () => {
|
||||
return activeEditor?.immutableId;
|
||||
};
|
||||
|
||||
// Memoized version to avoid repeated store lookups when active editor hasn't changed
|
||||
const getActiveEditorId = memoizeOne(getActiveEditorImmutableId);
|
||||
|
||||
const predicate = (actionType: string): AnyListenerPredicate<RootState> => {
|
||||
// Capture the immutable ID of the active editor at the time the listener is created
|
||||
// This ID never changes for a tab, ensuring stable event routing
|
||||
const registrationImmutableId = getActiveEditorId();
|
||||
const registrationImmutableId = getActiveEditorImmutableId();
|
||||
|
||||
return action => {
|
||||
if (action.type !== actionType) return false;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
Typography,
|
||||
Icons,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { capitalize } from 'lodash/fp';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
@@ -82,6 +82,26 @@ export default function Login() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const bootstrapData = getBootstrapData();
|
||||
const nextUrl = useMemo(() => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('next') || '';
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loginEndpoint = useMemo(
|
||||
() => (nextUrl ? `/login/?next=${encodeURIComponent(nextUrl)}` : '/login/'),
|
||||
[nextUrl],
|
||||
);
|
||||
|
||||
const buildProviderLoginUrl = (providerName: string) => {
|
||||
const base = `/login/${providerName}`;
|
||||
return nextUrl
|
||||
? `${base}${base.includes('?') ? '&' : '?'}next=${encodeURIComponent(nextUrl)}`
|
||||
: base;
|
||||
};
|
||||
|
||||
const authType: AuthType = bootstrapData.common.conf.AUTH_TYPE;
|
||||
const providers: Provider[] = bootstrapData.common.conf.AUTH_PROVIDERS;
|
||||
@@ -109,7 +129,7 @@ export default function Login() {
|
||||
sessionStorage.setItem('login_attempted', 'true');
|
||||
|
||||
// Use standard form submission for Flask-AppBuilder compatibility
|
||||
SupersetClient.postForm('/login/', values, '');
|
||||
SupersetClient.postForm(loginEndpoint, values, '');
|
||||
};
|
||||
|
||||
const getAuthIconElement = (
|
||||
@@ -146,7 +166,7 @@ export default function Login() {
|
||||
{providers.map((provider: OIDProvider) => (
|
||||
<Form.Item<LoginForm>>
|
||||
<Button
|
||||
href={`/login/${provider.name}`}
|
||||
href={buildProviderLoginUrl(provider.name)}
|
||||
block
|
||||
iconPosition="start"
|
||||
icon={getAuthIconElement(provider.name)}
|
||||
@@ -164,7 +184,7 @@ export default function Login() {
|
||||
{providers.map((provider: OAuthProvider) => (
|
||||
<Form.Item<LoginForm>>
|
||||
<Button
|
||||
href={`/login/${provider.name}`}
|
||||
href={buildProviderLoginUrl(provider.name)}
|
||||
block
|
||||
iconPosition="start"
|
||||
icon={getAuthIconElement(provider.name)}
|
||||
|
||||
@@ -276,3 +276,123 @@ test('handles various resource types', async () => {
|
||||
|
||||
expect(doneMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
test('handles network errors and logs them', async () => {
|
||||
const networkError = new Error('Network request failed');
|
||||
(SupersetClient.get as jest.Mock).mockRejectedValue(networkError);
|
||||
|
||||
const doneMock = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleResourceExport('dashboard', [1], doneMock),
|
||||
).rejects.toThrow('Network request failed');
|
||||
|
||||
expect(logging.error).toHaveBeenCalledWith(
|
||||
'Resource export failed:',
|
||||
networkError,
|
||||
);
|
||||
expect(doneMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles 404 errors when resource not found', async () => {
|
||||
const notFoundError = new Error('Not found');
|
||||
(SupersetClient.get as jest.Mock).mockRejectedValue(notFoundError);
|
||||
|
||||
const doneMock = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleResourceExport('dashboard', [999], doneMock),
|
||||
).rejects.toThrow('Not found');
|
||||
|
||||
expect(doneMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles empty response from server', async () => {
|
||||
const emptyBlob = new Blob([], { type: 'application/zip' });
|
||||
mockResponse = {
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="empty.zip"',
|
||||
}),
|
||||
blob: jest.fn().mockResolvedValue(emptyBlob),
|
||||
} as unknown as Response;
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const doneMock = jest.fn();
|
||||
await handleResourceExport('dashboard', [1], doneMock);
|
||||
|
||||
expect(window.URL.createObjectURL).toHaveBeenCalledWith(emptyBlob);
|
||||
expect(doneMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cleans up blob URL even when download fails', async () => {
|
||||
const mockAnchor = document.createElement('a');
|
||||
mockAnchor.click = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Click failed');
|
||||
});
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockAnchor);
|
||||
|
||||
const doneMock = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleResourceExport('dashboard', [1], doneMock),
|
||||
).rejects.toThrow('Click failed');
|
||||
|
||||
// Verify cleanup still happens
|
||||
expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||
expect(doneMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles malformed Content-Disposition header', async () => {
|
||||
mockResponse = {
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'not-a-valid-header',
|
||||
}),
|
||||
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||
} as unknown as Response;
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
|
||||
throw new Error('Parse error');
|
||||
});
|
||||
|
||||
const doneMock = jest.fn();
|
||||
await handleResourceExport('dataset', [5], doneMock);
|
||||
|
||||
// Should fall back to default filename
|
||||
const anchor = document.createElement('a');
|
||||
expect(anchor.download).toBe('dataset_export.zip');
|
||||
expect(logging.warn).toHaveBeenCalledWith(
|
||||
'Failed to parse Content-Disposition header:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
test('handles missing headers object', async () => {
|
||||
mockResponse = {
|
||||
headers: new Headers(),
|
||||
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||
} as unknown as Response;
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const doneMock = jest.fn();
|
||||
await handleResourceExport('chart', [7], doneMock);
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
expect(anchor.download).toBe('chart_export.zip');
|
||||
expect(doneMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('handles export with empty IDs array', async () => {
|
||||
const doneMock = jest.fn();
|
||||
await handleResourceExport('dashboard', [], doneMock);
|
||||
|
||||
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
endpoint: '/api/v1/dashboard/export/?q=!()',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Generated
+48
-48
@@ -25,12 +25,12 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.0",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^16.4.0",
|
||||
@@ -750,12 +750,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
|
||||
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
|
||||
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@eslint/object-schema": "^2.1.6",
|
||||
"@eslint/object-schema": "^2.1.7",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
},
|
||||
@@ -764,9 +765,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
|
||||
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
|
||||
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -847,9 +848,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
|
||||
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
|
||||
"version": "9.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
|
||||
"integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -860,10 +861,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
|
||||
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
@@ -1857,9 +1859,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
|
||||
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
|
||||
"version": "24.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
|
||||
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2839,25 +2841,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"version": "9.38.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
|
||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.0",
|
||||
"@eslint/config-helpers": "^0.4.0",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.1",
|
||||
"@eslint/core": "^0.16.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@eslint/js": "9.38.0",
|
||||
"@eslint/plugin-kit": "^0.4.0",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
@@ -7163,20 +7164,20 @@
|
||||
"dev": true
|
||||
},
|
||||
"@eslint/config-array": {
|
||||
"version": "0.21.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
|
||||
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
|
||||
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint/object-schema": "^2.1.6",
|
||||
"@eslint/object-schema": "^2.1.7",
|
||||
"debug": "^4.3.1",
|
||||
"minimatch": "^3.1.2"
|
||||
}
|
||||
},
|
||||
"@eslint/config-helpers": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz",
|
||||
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz",
|
||||
"integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint/core": "^0.16.0"
|
||||
@@ -7232,15 +7233,15 @@
|
||||
}
|
||||
},
|
||||
"@eslint/js": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz",
|
||||
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==",
|
||||
"version": "9.38.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz",
|
||||
"integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==",
|
||||
"dev": true
|
||||
},
|
||||
"@eslint/object-schema": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
|
||||
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
|
||||
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
|
||||
"dev": true
|
||||
},
|
||||
"@eslint/plugin-kit": {
|
||||
@@ -8056,9 +8057,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "24.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
|
||||
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
|
||||
"version": "24.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
|
||||
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~7.14.0"
|
||||
@@ -8742,24 +8743,23 @@
|
||||
"dev": true
|
||||
},
|
||||
"eslint": {
|
||||
"version": "9.37.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"version": "9.38.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz",
|
||||
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
"@eslint/config-array": "^0.21.0",
|
||||
"@eslint/config-helpers": "^0.4.0",
|
||||
"@eslint/config-array": "^0.21.1",
|
||||
"@eslint/config-helpers": "^0.4.1",
|
||||
"@eslint/core": "^0.16.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@eslint/js": "9.38.0",
|
||||
"@eslint/plugin-kit": "^0.4.0",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"ajv": "^6.12.4",
|
||||
"chalk": "^4.0.0",
|
||||
"cross-spawn": "^7.0.6",
|
||||
|
||||
@@ -33,12 +33,12 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.0",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^16.4.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
"""Create semantic layer command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow.validate import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerCreateFailedError,
|
||||
SemanticLayerExistsValidationError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerRequiredFieldValidationError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
|
||||
class CreateSemanticLayerCommand(BaseCommand):
|
||||
"""Command to create a semantic layer."""
|
||||
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=SemanticLayerCreateFailedError))
|
||||
def run(self) -> Model:
|
||||
"""
|
||||
Create a semantic layer.
|
||||
|
||||
:return: The created semantic layer
|
||||
"""
|
||||
self.validate()
|
||||
return SemanticLayerDAO.create(attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate the semantic layer data.
|
||||
|
||||
:raises SemanticLayerInvalidError: If validation fails
|
||||
"""
|
||||
exceptions: list[ValidationError] = []
|
||||
|
||||
# Validate required fields
|
||||
if not self._properties.get("name"):
|
||||
exceptions.append(SemanticLayerRequiredFieldValidationError("name"))
|
||||
|
||||
if not self._properties.get("type"):
|
||||
exceptions.append(SemanticLayerRequiredFieldValidationError("type"))
|
||||
|
||||
# Validate uniqueness
|
||||
name = self._properties.get("name")
|
||||
if name and not SemanticLayerDAO.validate_uniqueness(name):
|
||||
exceptions.append(SemanticLayerExistsValidationError())
|
||||
|
||||
if exceptions:
|
||||
exception = SemanticLayerInvalidError()
|
||||
exception.extend(exceptions)
|
||||
raise exception
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
"""Delete semantic layer command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerDeleteFailedError,
|
||||
SemanticLayerNotFoundError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.semantic_layers.models import SemanticLayer
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
|
||||
class DeleteSemanticLayerCommand(BaseCommand):
|
||||
"""Command to delete a semantic layer."""
|
||||
|
||||
def __init__(self, model_id: str):
|
||||
self._model_id = model_id
|
||||
self._model: SemanticLayer | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=SemanticLayerDeleteFailedError))
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Delete a semantic layer.
|
||||
|
||||
Semantic views will be cascade deleted.
|
||||
"""
|
||||
self.validate()
|
||||
assert self._model
|
||||
SemanticLayerDAO.delete([self._model])
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate the semantic layer deletion.
|
||||
|
||||
:raises SemanticLayerNotFoundError: If semantic layer not found
|
||||
"""
|
||||
self._model = SemanticLayerDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
@@ -0,0 +1,76 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Exceptions for semantic layer commands."""
|
||||
|
||||
from flask_babel import lazy_gettext as _
|
||||
from marshmallow.validate import ValidationError
|
||||
|
||||
from superset.commands.exceptions import (
|
||||
CommandInvalidError,
|
||||
CreateFailedError,
|
||||
DeleteFailedError,
|
||||
ObjectNotFoundError,
|
||||
UpdateFailedError,
|
||||
)
|
||||
|
||||
|
||||
class SemanticLayerInvalidError(CommandInvalidError):
|
||||
"""Semantic layer parameters are invalid."""
|
||||
|
||||
message = _("Semantic layer parameters are invalid.")
|
||||
|
||||
|
||||
class SemanticLayerNotFoundError(ObjectNotFoundError):
|
||||
"""Semantic layer not found."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Semantic layer", None)
|
||||
|
||||
|
||||
class SemanticLayerCreateFailedError(CreateFailedError):
|
||||
"""Semantic layer could not be created."""
|
||||
|
||||
message = _("Semantic layer could not be created.")
|
||||
|
||||
|
||||
class SemanticLayerUpdateFailedError(UpdateFailedError):
|
||||
"""Semantic layer could not be updated."""
|
||||
|
||||
message = _("Semantic layer could not be updated.")
|
||||
|
||||
|
||||
class SemanticLayerDeleteFailedError(DeleteFailedError):
|
||||
"""Semantic layer could not be deleted."""
|
||||
|
||||
message = _("Semantic layer could not be deleted.")
|
||||
|
||||
|
||||
class SemanticLayerRequiredFieldValidationError(ValidationError):
|
||||
"""Required field validation error."""
|
||||
|
||||
def __init__(self, field_name: str) -> None:
|
||||
super().__init__([_("Field is required")], field_name=field_name)
|
||||
|
||||
|
||||
class SemanticLayerExistsValidationError(ValidationError):
|
||||
"""Semantic layer already exists validation error."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
[_("A semantic layer with this name already exists")],
|
||||
field_name="name",
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
"""Update semantic layer command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow.validate import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerExistsValidationError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
SemanticLayerUpdateFailedError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO
|
||||
from superset.semantic_layers.models import SemanticLayer
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
|
||||
class UpdateSemanticLayerCommand(BaseCommand):
|
||||
"""Command to update a semantic layer."""
|
||||
|
||||
def __init__(self, model_id: str, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
self._model_id = model_id
|
||||
self._model: SemanticLayer | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=SemanticLayerUpdateFailedError))
|
||||
def run(self) -> Model:
|
||||
"""
|
||||
Update a semantic layer.
|
||||
|
||||
:return: The updated semantic layer
|
||||
"""
|
||||
self.validate()
|
||||
assert self._model
|
||||
|
||||
return SemanticLayerDAO.update(self._model, self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate the semantic layer update.
|
||||
|
||||
:raises SemanticLayerNotFoundError: If semantic layer not found
|
||||
:raises SemanticLayerInvalidError: If validation fails
|
||||
"""
|
||||
exceptions: list[ValidationError] = []
|
||||
|
||||
# Find the model
|
||||
self._model = SemanticLayerDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
# Validate uniqueness if name is being changed
|
||||
if name := self._properties.get("name"):
|
||||
if not SemanticLayerDAO.validate_update_uniqueness(self._model_id, name):
|
||||
exceptions.append(SemanticLayerExistsValidationError())
|
||||
|
||||
if exceptions:
|
||||
exception = SemanticLayerInvalidError()
|
||||
exception.extend(exceptions)
|
||||
raise exception
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
"""Create semantic view command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow.validate import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
|
||||
from superset.commands.semantic_view.exceptions import (
|
||||
SemanticViewCreateFailedError,
|
||||
SemanticViewExistsValidationError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewRequiredFieldValidationError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
|
||||
class CreateSemanticViewCommand(BaseCommand):
|
||||
"""Command to create a semantic view."""
|
||||
|
||||
def __init__(self, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=SemanticViewCreateFailedError))
|
||||
def run(self) -> Model:
|
||||
"""
|
||||
Create a semantic view.
|
||||
|
||||
:return: The created semantic view
|
||||
"""
|
||||
self.validate()
|
||||
return SemanticViewDAO.create(attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate the semantic view data.
|
||||
|
||||
:raises SemanticViewInvalidError: If validation fails
|
||||
:raises SemanticLayerNotFoundError: If semantic layer not found
|
||||
"""
|
||||
exceptions: list[ValidationError] = []
|
||||
|
||||
# Validate required fields
|
||||
if not self._properties.get("name"):
|
||||
exceptions.append(SemanticViewRequiredFieldValidationError("name"))
|
||||
|
||||
layer_uuid = self._properties.get("semantic_layer_uuid")
|
||||
if not layer_uuid:
|
||||
exceptions.append(
|
||||
SemanticViewRequiredFieldValidationError("semantic_layer_uuid")
|
||||
)
|
||||
else:
|
||||
# Validate semantic layer exists
|
||||
semantic_layer = SemanticLayerDAO.find_by_id(layer_uuid)
|
||||
if not semantic_layer:
|
||||
raise SemanticLayerNotFoundError()
|
||||
|
||||
# Validate uniqueness within semantic layer
|
||||
name = self._properties.get("name")
|
||||
if name and not SemanticViewDAO.validate_uniqueness(name, layer_uuid):
|
||||
exceptions.append(SemanticViewExistsValidationError())
|
||||
|
||||
if exceptions:
|
||||
exception = SemanticViewInvalidError()
|
||||
exception.extend(exceptions)
|
||||
raise exception
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
"""Delete semantic view command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_view.exceptions import (
|
||||
SemanticViewDeleteFailedError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticViewDAO
|
||||
from superset.semantic_layers.models import SemanticView
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
|
||||
class DeleteSemanticViewCommand(BaseCommand):
|
||||
"""Command to delete a semantic view."""
|
||||
|
||||
def __init__(self, model_id: str):
|
||||
self._model_id = model_id
|
||||
self._model: SemanticView | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=SemanticViewDeleteFailedError))
|
||||
def run(self) -> None:
|
||||
"""Delete a semantic view."""
|
||||
self.validate()
|
||||
assert self._model
|
||||
SemanticViewDAO.delete([self._model])
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate the semantic view deletion.
|
||||
|
||||
:raises SemanticViewNotFoundError: If semantic view not found
|
||||
"""
|
||||
self._model = SemanticViewDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
@@ -0,0 +1,76 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Exceptions for semantic view commands."""
|
||||
|
||||
from flask_babel import lazy_gettext as _
|
||||
from marshmallow.validate import ValidationError
|
||||
|
||||
from superset.commands.exceptions import (
|
||||
CommandInvalidError,
|
||||
CreateFailedError,
|
||||
DeleteFailedError,
|
||||
ObjectNotFoundError,
|
||||
UpdateFailedError,
|
||||
)
|
||||
|
||||
|
||||
class SemanticViewInvalidError(CommandInvalidError):
|
||||
"""Semantic view parameters are invalid."""
|
||||
|
||||
message = _("Semantic view parameters are invalid.")
|
||||
|
||||
|
||||
class SemanticViewNotFoundError(ObjectNotFoundError):
|
||||
"""Semantic view not found."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Semantic view", None)
|
||||
|
||||
|
||||
class SemanticViewCreateFailedError(CreateFailedError):
|
||||
"""Semantic view could not be created."""
|
||||
|
||||
message = _("Semantic view could not be created.")
|
||||
|
||||
|
||||
class SemanticViewUpdateFailedError(UpdateFailedError):
|
||||
"""Semantic view could not be updated."""
|
||||
|
||||
message = _("Semantic view could not be updated.")
|
||||
|
||||
|
||||
class SemanticViewDeleteFailedError(DeleteFailedError):
|
||||
"""Semantic view could not be deleted."""
|
||||
|
||||
message = _("Semantic view could not be deleted.")
|
||||
|
||||
|
||||
class SemanticViewRequiredFieldValidationError(ValidationError):
|
||||
"""Required field validation error."""
|
||||
|
||||
def __init__(self, field_name: str) -> None:
|
||||
super().__init__([_("Field is required")], field_name=field_name)
|
||||
|
||||
|
||||
class SemanticViewExistsValidationError(ValidationError):
|
||||
"""Semantic view already exists validation error."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
[_("A semantic view with this name already exists in this semantic layer")],
|
||||
field_name="name",
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
"""Update semantic view command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow.validate import ValidationError
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.semantic_view.exceptions import (
|
||||
SemanticViewExistsValidationError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewNotFoundError,
|
||||
SemanticViewUpdateFailedError,
|
||||
)
|
||||
from superset.daos.semantic_layer import SemanticViewDAO
|
||||
from superset.semantic_layers.models import SemanticView
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
|
||||
class UpdateSemanticViewCommand(BaseCommand):
|
||||
"""Command to update a semantic view."""
|
||||
|
||||
def __init__(self, model_id: str, data: dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
self._model_id = model_id
|
||||
self._model: SemanticView | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=SemanticViewUpdateFailedError))
|
||||
def run(self) -> Model:
|
||||
"""
|
||||
Update a semantic view.
|
||||
|
||||
:return: The updated semantic view
|
||||
"""
|
||||
self.validate()
|
||||
assert self._model
|
||||
|
||||
return SemanticViewDAO.update(self._model, self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate the semantic view update.
|
||||
|
||||
:raises SemanticViewNotFoundError: If semantic view not found
|
||||
:raises SemanticViewInvalidError: If validation fails
|
||||
"""
|
||||
exceptions: list[ValidationError] = []
|
||||
|
||||
# Find the model
|
||||
self._model = SemanticViewDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise SemanticViewNotFoundError()
|
||||
|
||||
# Validate uniqueness if name is being changed
|
||||
if name := self._properties.get("name"):
|
||||
if not SemanticViewDAO.validate_update_uniqueness(
|
||||
self._model_id, name, self._model.semantic_layer_uuid
|
||||
):
|
||||
exceptions.append(SemanticViewExistsValidationError())
|
||||
|
||||
if exceptions:
|
||||
exception = SemanticViewInvalidError()
|
||||
exception.extend(exceptions)
|
||||
raise exception
|
||||
@@ -0,0 +1,152 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""DAOs for semantic layer models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.extensions import db
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
|
||||
|
||||
class SemanticLayerDAO(BaseDAO[SemanticLayer]):
|
||||
"""
|
||||
Data Access Object for SemanticLayer model.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def validate_uniqueness(name: str) -> bool:
|
||||
"""
|
||||
Validate that semantic layer name is unique.
|
||||
|
||||
:param name: Semantic layer name
|
||||
:return: True if name is unique, False otherwise
|
||||
"""
|
||||
query = db.session.query(SemanticLayer).filter(SemanticLayer.name == name)
|
||||
return not db.session.query(query.exists()).scalar()
|
||||
|
||||
@staticmethod
|
||||
def validate_update_uniqueness(layer_uuid: str, name: str) -> bool:
|
||||
"""
|
||||
Validate that semantic layer name is unique for updates.
|
||||
|
||||
:param layer_uuid: UUID of the semantic layer being updated
|
||||
:param name: New name to validate
|
||||
:return: True if name is unique, False otherwise
|
||||
"""
|
||||
query = db.session.query(SemanticLayer).filter(
|
||||
SemanticLayer.name == name,
|
||||
SemanticLayer.uuid != layer_uuid,
|
||||
)
|
||||
return not db.session.query(query.exists()).scalar()
|
||||
|
||||
@staticmethod
|
||||
def find_by_name(name: str) -> SemanticLayer | None:
|
||||
"""
|
||||
Find semantic layer by name.
|
||||
|
||||
:param name: Semantic layer name
|
||||
:return: SemanticLayer instance or None
|
||||
"""
|
||||
return (
|
||||
db.session.query(SemanticLayer)
|
||||
.filter(SemanticLayer.name == name)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_semantic_views(cls, layer_uuid: str) -> list[SemanticView]:
|
||||
"""
|
||||
Get all semantic views for a semantic layer.
|
||||
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: List of SemanticView instances
|
||||
"""
|
||||
return (
|
||||
db.session.query(SemanticView)
|
||||
.filter(SemanticView.semantic_layer_uuid == layer_uuid)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class SemanticViewDAO(BaseDAO[SemanticView]):
|
||||
"""Data Access Object for SemanticView model."""
|
||||
|
||||
@staticmethod
|
||||
def find_by_semantic_layer(layer_uuid: str) -> list[SemanticView]:
|
||||
"""
|
||||
Find all views for a semantic layer.
|
||||
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: List of SemanticView instances
|
||||
"""
|
||||
return (
|
||||
db.session.query(SemanticView)
|
||||
.filter(SemanticView.semantic_layer_uuid == layer_uuid)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_uniqueness(name: str, layer_uuid: str) -> bool:
|
||||
"""
|
||||
Validate that view name is unique within semantic layer.
|
||||
|
||||
:param name: View name
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: True if name is unique within layer, False otherwise
|
||||
"""
|
||||
query = db.session.query(SemanticView).filter(
|
||||
SemanticView.name == name,
|
||||
SemanticView.semantic_layer_uuid == layer_uuid,
|
||||
)
|
||||
return not db.session.query(query.exists()).scalar()
|
||||
|
||||
@staticmethod
|
||||
def validate_update_uniqueness(view_uuid: str, name: str, layer_uuid: str) -> bool:
|
||||
"""
|
||||
Validate that view name is unique within semantic layer for updates.
|
||||
|
||||
:param view_uuid: UUID of the view being updated
|
||||
:param name: New name to validate
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: True if name is unique within layer, False otherwise
|
||||
"""
|
||||
query = db.session.query(SemanticView).filter(
|
||||
SemanticView.name == name,
|
||||
SemanticView.semantic_layer_uuid == layer_uuid,
|
||||
SemanticView.uuid != view_uuid,
|
||||
)
|
||||
return not db.session.query(query.exists()).scalar()
|
||||
|
||||
@staticmethod
|
||||
def find_by_name(name: str, layer_uuid: str) -> SemanticView | None:
|
||||
"""
|
||||
Find semantic view by name within a semantic layer.
|
||||
|
||||
:param name: View name
|
||||
:param layer_uuid: UUID of the semantic layer
|
||||
:return: SemanticView instance or None
|
||||
"""
|
||||
return (
|
||||
db.session.query(SemanticView)
|
||||
.filter(
|
||||
SemanticView.name == name,
|
||||
SemanticView.semantic_layer_uuid == layer_uuid,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,248 @@
|
||||
# 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 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
|
||||
"""
|
||||
|
||||
@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
|
||||
"""
|
||||
|
||||
@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
|
||||
"""
|
||||
|
||||
@property
|
||||
def schema_perm(self) -> str | None:
|
||||
"""
|
||||
Schema-level permission string.
|
||||
|
||||
Optional permission string for schema-level access control.
|
||||
Some explorables don't have a schema concept and can return None.
|
||||
|
||||
:return: Schema permission string, or None
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# 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)
|
||||
"""
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""add_semantic_layers_and_views
|
||||
|
||||
Revision ID: 33d7e0e21daa
|
||||
Revises: c233f5365c9e
|
||||
Create Date: 2025-11-04 11:26:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.migrations.shared.utils import (
|
||||
create_fks_for_table,
|
||||
create_table,
|
||||
drop_table,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "33d7e0e21daa"
|
||||
down_revision = "c233f5365c9e"
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create semantic_layers table
|
||||
create_table(
|
||||
"semantic_layers",
|
||||
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, 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),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("type", sa.String(length=250), nullable=False),
|
||||
sa.Column(
|
||||
"configuration",
|
||||
sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("cache_timeout", sa.Integer(), nullable=True),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("uuid"),
|
||||
)
|
||||
|
||||
# Create foreign key constraints for semantic_layers
|
||||
create_fks_for_table(
|
||||
"fk_semantic_layers_created_by_fk_ab_user",
|
||||
"semantic_layers",
|
||||
"ab_user",
|
||||
["created_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
create_fks_for_table(
|
||||
"fk_semantic_layers_changed_by_fk_ab_user",
|
||||
"semantic_layers",
|
||||
"ab_user",
|
||||
["changed_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
# Create semantic_views table
|
||||
create_table(
|
||||
"semantic_views",
|
||||
sa.Column("uuid", UUIDType(binary=True), default=uuid.uuid4, 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),
|
||||
sa.Column(
|
||||
"configuration",
|
||||
sa.Text().with_variant(mysql.MEDIUMTEXT(), "mysql"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("cache_timeout", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"semantic_layer_uuid",
|
||||
UUIDType(binary=True),
|
||||
sa.ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("uuid"),
|
||||
)
|
||||
|
||||
# Create foreign key constraints for semantic_views
|
||||
create_fks_for_table(
|
||||
"fk_semantic_views_created_by_fk_ab_user",
|
||||
"semantic_views",
|
||||
"ab_user",
|
||||
["created_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
create_fks_for_table(
|
||||
"fk_semantic_views_changed_by_fk_ab_user",
|
||||
"semantic_views",
|
||||
"ab_user",
|
||||
["changed_by_fk"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
drop_table("semantic_views")
|
||||
drop_table("semantic_layers")
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,869 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Functions for mapping `QueryObject` to semantic layers.
|
||||
|
||||
These functions validate and convert a `QueryObject` into one or more `SemanticQuery`,
|
||||
which are then passed to semantic layer implementations for execution, returning a
|
||||
single dataframe.
|
||||
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from time import time
|
||||
from typing import Any, cast, Sequence, TypeGuard
|
||||
|
||||
import numpy as np
|
||||
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.common.utils.time_range_utils import get_since_until_from_query_object
|
||||
from superset.connectors.sqla.models import BaseDatasource
|
||||
from superset.models.helpers import QueryResult
|
||||
from superset.semantic_layers.types import (
|
||||
AdhocExpression,
|
||||
AdhocFilter,
|
||||
DateGrain,
|
||||
Dimension,
|
||||
Filter,
|
||||
FilterValues,
|
||||
GroupLimit,
|
||||
Metric,
|
||||
Operator,
|
||||
OrderDirection,
|
||||
OrderTuple,
|
||||
PredicateType,
|
||||
SemanticQuery,
|
||||
SemanticResult,
|
||||
SemanticViewFeature,
|
||||
TimeGrain,
|
||||
)
|
||||
from superset.utils.core import (
|
||||
FilterOperator,
|
||||
QueryObjectFilterClause,
|
||||
TIME_COMPARISON,
|
||||
)
|
||||
from superset.utils.date_parser import get_past_or_future
|
||||
|
||||
|
||||
class ValidatedQueryObjectFilterClause(QueryObjectFilterClause):
|
||||
"""
|
||||
A validated QueryObject filter clause with a string column name.
|
||||
|
||||
The `col` in a `QueryObjectFilterClause` can be either a string (column name) or an
|
||||
adhoc column, but we only support the former in semantic layers.
|
||||
"""
|
||||
|
||||
# overwrite to narrow type; mypy complains about more restrictive typed dicts,
|
||||
# but the alternative would be to redefine the object
|
||||
col: str # type: ignore[misc]
|
||||
op: str # type: ignore[misc]
|
||||
|
||||
|
||||
class ValidatedQueryObject(QueryObject):
|
||||
"""
|
||||
A query object that has a datasource defined.
|
||||
"""
|
||||
|
||||
datasource: BaseDatasource
|
||||
|
||||
# overwrite to narrow type; mypy complains about the assignment since the base type
|
||||
# allows adhoc filters, but we only support validated filters here
|
||||
filter: list[ValidatedQueryObjectFilterClause] # type: ignore[assignment]
|
||||
series_columns: Sequence[str] # type: ignore[assignment]
|
||||
series_limit_metric: str | None
|
||||
|
||||
|
||||
def get_results(query_object: QueryObject) -> QueryResult:
|
||||
"""
|
||||
Run 1+ queries based on `QueryObject` and return the results.
|
||||
|
||||
:param query_object: The QueryObject containing query specifications
|
||||
:return: QueryResult compatible with Superset's query interface
|
||||
"""
|
||||
if not validate_query_object(query_object):
|
||||
raise ValueError("QueryObject must have a datasource defined.")
|
||||
|
||||
# Track execution time
|
||||
start_time = time()
|
||||
|
||||
semantic_view = query_object.datasource.implementation
|
||||
dispatcher = (
|
||||
semantic_view.get_row_count
|
||||
if query_object.is_rowcount
|
||||
else semantic_view.get_dataframe
|
||||
)
|
||||
|
||||
# Step 1: Convert QueryObject to list of SemanticQuery objects
|
||||
# The first query is the main query, subsequent queries are for time offsets
|
||||
queries = map_query_object(query_object)
|
||||
|
||||
# Step 2: Execute the main query (first in the list)
|
||||
main_query = queries[0]
|
||||
main_result = dispatcher(
|
||||
metrics=main_query.metrics,
|
||||
dimensions=main_query.dimensions,
|
||||
filters=main_query.filters,
|
||||
order=main_query.order,
|
||||
limit=main_query.limit,
|
||||
offset=main_query.offset,
|
||||
group_limit=main_query.group_limit,
|
||||
)
|
||||
|
||||
main_df = main_result.results
|
||||
|
||||
# Collect all requests (SQL queries, HTTP requests, etc.) for troubleshooting
|
||||
all_requests = list(main_result.requests)
|
||||
|
||||
# If no time offsets, return the main result as-is
|
||||
if not query_object.time_offsets or len(queries) <= 1:
|
||||
semantic_result = SemanticResult(
|
||||
requests=all_requests,
|
||||
results=main_df,
|
||||
)
|
||||
duration = timedelta(seconds=time() - start_time)
|
||||
return map_semantic_result_to_query_result(
|
||||
semantic_result,
|
||||
query_object,
|
||||
duration,
|
||||
)
|
||||
|
||||
# Get metric names from the main query
|
||||
# These are the columns that will be renamed with offset suffixes
|
||||
metric_names = [metric.name for metric in main_query.metrics]
|
||||
|
||||
# Join keys are all columns except metrics
|
||||
# These will be used to match rows between main and offset DataFrames
|
||||
join_keys = [col for col in main_df.columns if col not in metric_names]
|
||||
|
||||
# Step 3 & 4: Execute each time offset query and join results
|
||||
for offset_query, time_offset in zip(
|
||||
queries[1:],
|
||||
query_object.time_offsets,
|
||||
strict=False,
|
||||
):
|
||||
# Execute the offset query
|
||||
result = dispatcher(
|
||||
metrics=offset_query.metrics,
|
||||
dimensions=offset_query.dimensions,
|
||||
filters=offset_query.filters,
|
||||
order=offset_query.order,
|
||||
limit=offset_query.limit,
|
||||
offset=offset_query.offset,
|
||||
group_limit=offset_query.group_limit,
|
||||
)
|
||||
|
||||
# Add this query's requests to the collection
|
||||
all_requests.extend(result.requests)
|
||||
|
||||
offset_df = result.results
|
||||
|
||||
# Handle empty results - add NaN columns directly instead of merging
|
||||
# This avoids dtype mismatch issues with empty DataFrames
|
||||
if offset_df.empty:
|
||||
# Add offset metric columns with NaN values directly to main_df
|
||||
for metric in metric_names:
|
||||
offset_col_name = TIME_COMPARISON.join([metric, time_offset])
|
||||
main_df[offset_col_name] = np.nan
|
||||
else:
|
||||
# Rename metric columns with time offset suffix
|
||||
# Format: "{metric_name}__{time_offset}"
|
||||
# Example: "revenue" -> "revenue__1 week ago"
|
||||
offset_df = offset_df.rename(
|
||||
columns={
|
||||
metric: TIME_COMPARISON.join([metric, time_offset])
|
||||
for metric in metric_names
|
||||
}
|
||||
)
|
||||
|
||||
# Step 5: Perform left join on dimension columns
|
||||
# This preserves all rows from main_df and adds offset metrics
|
||||
# where they match
|
||||
main_df = main_df.merge(
|
||||
offset_df,
|
||||
on=join_keys,
|
||||
how="left",
|
||||
suffixes=("", "__duplicate"),
|
||||
)
|
||||
|
||||
# Clean up any duplicate columns that might have been created
|
||||
# (shouldn't happen with proper join keys, but defensive programming)
|
||||
duplicate_cols = [
|
||||
col for col in main_df.columns if col.endswith("__duplicate")
|
||||
]
|
||||
if duplicate_cols:
|
||||
main_df = main_df.drop(columns=duplicate_cols)
|
||||
|
||||
# Convert final result to QueryResult
|
||||
semantic_result = SemanticResult(requests=all_requests, results=main_df)
|
||||
duration = timedelta(seconds=time() - start_time)
|
||||
return map_semantic_result_to_query_result(
|
||||
semantic_result,
|
||||
query_object,
|
||||
duration,
|
||||
)
|
||||
|
||||
|
||||
def map_semantic_result_to_query_result(
|
||||
semantic_result: SemanticResult,
|
||||
query_object: ValidatedQueryObject,
|
||||
duration: timedelta,
|
||||
) -> QueryResult:
|
||||
"""
|
||||
Convert a SemanticResult to a QueryResult.
|
||||
|
||||
:param semantic_result: Result from the semantic layer
|
||||
:param query_object: Original QueryObject (for passthrough attributes)
|
||||
:param duration: Time taken to execute the query
|
||||
:return: QueryResult compatible with Superset's query interface
|
||||
"""
|
||||
# Get the query string from requests (typically one or more SQL queries)
|
||||
query_str = ""
|
||||
if semantic_result.requests:
|
||||
# Join all requests for display (could be multiple for time comparisons)
|
||||
query_str = "\n\n".join(
|
||||
f"-- {req.type}\n{req.definition}" for req in semantic_result.requests
|
||||
)
|
||||
|
||||
return QueryResult(
|
||||
# Core data
|
||||
df=semantic_result.results,
|
||||
query=query_str,
|
||||
duration=duration,
|
||||
# Template filters - not applicable to semantic layers
|
||||
# (semantic layers don't use Jinja templates)
|
||||
applied_template_filters=None,
|
||||
# Filter columns - not applicable to semantic layers
|
||||
# (semantic layers handle filter validation internally)
|
||||
applied_filter_columns=None,
|
||||
rejected_filter_columns=None,
|
||||
# Status - always success if we got here
|
||||
# (errors would raise exceptions before reaching this point)
|
||||
status=QueryStatus.SUCCESS,
|
||||
error_message=None,
|
||||
errors=None,
|
||||
# Time range - pass through from original query_object
|
||||
from_dttm=query_object.from_dttm,
|
||||
to_dttm=query_object.to_dttm,
|
||||
)
|
||||
|
||||
|
||||
def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
|
||||
"""
|
||||
Convert a `QueryObject` into a list of `SemanticQuery`.
|
||||
|
||||
This function maps the `QueryObject` into query objects that focus less on
|
||||
visualization and more on semantics.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
all_metrics = {metric.name: metric for metric in semantic_view.metrics}
|
||||
all_dimensions = {
|
||||
dimension.name: dimension for dimension in semantic_view.dimensions
|
||||
}
|
||||
|
||||
metrics = [all_metrics[metric] for metric in (query_object.metrics or [])]
|
||||
|
||||
grain = (
|
||||
_convert_time_grain(query_object.extras["time_grain_sqla"])
|
||||
if "time_grain_sqla" in query_object.extras
|
||||
else None
|
||||
)
|
||||
dimensions = [
|
||||
dimension
|
||||
for dimension in semantic_view.dimensions
|
||||
if dimension.name in query_object.columns
|
||||
and (
|
||||
# if a grain is specified, only include the time dimension if its grain
|
||||
# matches the requested grain
|
||||
grain is None
|
||||
or dimension.name != query_object.granularity
|
||||
or dimension.grain == grain
|
||||
)
|
||||
]
|
||||
|
||||
order = _get_order_from_query_object(query_object, all_metrics, all_dimensions)
|
||||
limit = query_object.row_limit
|
||||
offset = query_object.row_offset
|
||||
|
||||
group_limit = _get_group_limit_from_query_object(
|
||||
query_object,
|
||||
all_metrics,
|
||||
all_dimensions,
|
||||
)
|
||||
|
||||
queries = []
|
||||
for time_offset in [None] + query_object.time_offsets:
|
||||
filters = _get_filters_from_query_object(
|
||||
query_object,
|
||||
time_offset,
|
||||
all_dimensions,
|
||||
)
|
||||
|
||||
queries.append(
|
||||
SemanticQuery(
|
||||
metrics=metrics,
|
||||
dimensions=dimensions,
|
||||
filters=filters,
|
||||
order=order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
group_limit=group_limit,
|
||||
)
|
||||
)
|
||||
|
||||
return queries
|
||||
|
||||
|
||||
def _get_filters_from_query_object(
|
||||
query_object: ValidatedQueryObject,
|
||||
time_offset: str | None,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> set[Filter | AdhocFilter]:
|
||||
"""
|
||||
Extract all filters from the query object, including time range filters.
|
||||
|
||||
This simplifies the complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm
|
||||
by converting all time constraints into filters.
|
||||
"""
|
||||
filters: set[Filter | AdhocFilter] = set()
|
||||
|
||||
# 1. Add fetch values predicate if present
|
||||
if (
|
||||
query_object.apply_fetch_values_predicate
|
||||
and query_object.datasource.fetch_values_predicate
|
||||
):
|
||||
filters.add(
|
||||
AdhocFilter(
|
||||
type=PredicateType.WHERE,
|
||||
definition=query_object.datasource.fetch_values_predicate,
|
||||
)
|
||||
)
|
||||
|
||||
# 2. Add time range filter based on from_dttm/to_dttm
|
||||
# For time offsets, this automatically calculates the shifted bounds
|
||||
time_filters = _get_time_filter(query_object, time_offset, all_dimensions)
|
||||
filters.update(time_filters)
|
||||
|
||||
# 3. Add filters from query_object.extras (WHERE and HAVING clauses)
|
||||
extras_filters = _get_filters_from_extras(query_object.extras)
|
||||
filters.update(extras_filters)
|
||||
|
||||
# 4. Add all other filters from query_object.filter
|
||||
for filter_ in query_object.filter:
|
||||
converted_filter = _convert_query_object_filter(filter_, all_dimensions)
|
||||
if converted_filter:
|
||||
filters.add(converted_filter)
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def _get_filters_from_extras(extras: dict[str, Any]) -> set[AdhocFilter]:
|
||||
"""
|
||||
Extract filters from the extras dict.
|
||||
|
||||
The extras dict can contain various keys that affect query behavior:
|
||||
|
||||
Supported keys (converted to filters):
|
||||
- "where": SQL WHERE clause expression (e.g., "customer_id > 100")
|
||||
- "having": SQL HAVING clause expression (e.g., "SUM(sales) > 1000")
|
||||
|
||||
Other keys in extras (handled elsewhere in the mapper):
|
||||
- "time_grain_sqla": Time granularity (e.g., "P1D", "PT1H")
|
||||
Handled in _convert_time_grain() and used for dimension grain matching
|
||||
|
||||
Note: The WHERE and HAVING clauses from extras are SQL expressions that
|
||||
are passed through as-is to the semantic layer as AdhocFilter objects.
|
||||
"""
|
||||
filters: set[AdhocFilter] = set()
|
||||
|
||||
# Add WHERE clause from extras
|
||||
if where_clause := extras.get("where"):
|
||||
filters.add(
|
||||
AdhocFilter(
|
||||
type=PredicateType.WHERE,
|
||||
definition=where_clause,
|
||||
)
|
||||
)
|
||||
|
||||
# Add HAVING clause from extras
|
||||
if having_clause := extras.get("having"):
|
||||
filters.add(
|
||||
AdhocFilter(
|
||||
type=PredicateType.HAVING,
|
||||
definition=having_clause,
|
||||
)
|
||||
)
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def _get_time_filter(
|
||||
query_object: ValidatedQueryObject,
|
||||
time_offset: str | None,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> set[Filter]:
|
||||
"""
|
||||
Create a time range filter from the query object.
|
||||
|
||||
This handles both regular queries and time offset queries, simplifying the
|
||||
complexity of from_dttm/to_dttm/inner_from_dttm/inner_to_dttm by using the
|
||||
same time bounds for both the main query and series limit subqueries.
|
||||
"""
|
||||
filters: set[Filter] = set()
|
||||
|
||||
if not query_object.granularity:
|
||||
return filters
|
||||
|
||||
time_dimension = all_dimensions.get(query_object.granularity)
|
||||
if not time_dimension:
|
||||
return filters
|
||||
|
||||
# Get the appropriate time bounds based on whether this is a time offset query
|
||||
from_dttm, to_dttm = _get_time_bounds(query_object, time_offset)
|
||||
|
||||
if not from_dttm or not to_dttm:
|
||||
return filters
|
||||
|
||||
# Create a filter with >= and < operators
|
||||
return {
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.GREATER_THAN_OR_EQUAL,
|
||||
value=from_dttm,
|
||||
),
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.LESS_THAN,
|
||||
value=to_dttm,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _get_time_bounds(
|
||||
query_object: ValidatedQueryObject,
|
||||
time_offset: str | None,
|
||||
) -> tuple[datetime | None, datetime | None]:
|
||||
"""
|
||||
Get the appropriate time bounds for the query.
|
||||
|
||||
For regular queries (time_offset is None), returns from_dttm/to_dttm.
|
||||
For time offset queries, calculates the shifted bounds.
|
||||
|
||||
This simplifies the inner_from_dttm/inner_to_dttm complexity by using
|
||||
the same bounds for both main queries and series limit subqueries (Option 1).
|
||||
"""
|
||||
if time_offset is None:
|
||||
# Main query: use from_dttm/to_dttm directly
|
||||
return query_object.from_dttm, query_object.to_dttm
|
||||
|
||||
# Time offset query: calculate shifted bounds
|
||||
# Use from_dttm/to_dttm if available, otherwise try to get from time_range
|
||||
outer_from = query_object.from_dttm
|
||||
outer_to = query_object.to_dttm
|
||||
|
||||
if not outer_from or not outer_to:
|
||||
# Fall back to parsing time_range if from_dttm/to_dttm not set
|
||||
outer_from, outer_to = get_since_until_from_query_object(query_object)
|
||||
|
||||
if not outer_from or not outer_to:
|
||||
return None, None
|
||||
|
||||
# Apply the offset to both bounds
|
||||
offset_from = get_past_or_future(time_offset, outer_from)
|
||||
offset_to = get_past_or_future(time_offset, outer_to)
|
||||
|
||||
return offset_from, offset_to
|
||||
|
||||
|
||||
def _convert_query_object_filter(
|
||||
filter_: ValidatedQueryObjectFilterClause,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> Filter | AdhocFilter | None:
|
||||
"""
|
||||
Convert a QueryObject filter dict to a semantic layer Filter or AdhocFilter.
|
||||
"""
|
||||
operator_str = filter_["op"]
|
||||
|
||||
# Handle TEMPORAL_RANGE filters (these are already handled by _get_time_filter)
|
||||
if operator_str == FilterOperator.TEMPORAL_RANGE.value:
|
||||
# Skip - already handled in _get_time_filter
|
||||
return None
|
||||
|
||||
# Handle simple column filters
|
||||
col = filter_.get("col")
|
||||
if col not in all_dimensions:
|
||||
return None
|
||||
|
||||
dimension = all_dimensions[col]
|
||||
|
||||
val_str = filter_["val"]
|
||||
value: FilterValues | set[FilterValues]
|
||||
if val_str is None:
|
||||
value = None
|
||||
elif isinstance(val_str, (list, tuple)):
|
||||
value = set(val_str)
|
||||
else:
|
||||
value = val_str
|
||||
|
||||
# Map QueryObject operators to semantic layer operators
|
||||
operator_mapping = {
|
||||
FilterOperator.EQUALS.value: Operator.EQUALS,
|
||||
FilterOperator.NOT_EQUALS.value: Operator.NOT_EQUALS,
|
||||
FilterOperator.GREATER_THAN.value: Operator.GREATER_THAN,
|
||||
FilterOperator.LESS_THAN.value: Operator.LESS_THAN,
|
||||
FilterOperator.GREATER_THAN_OR_EQUALS.value: Operator.GREATER_THAN_OR_EQUAL,
|
||||
FilterOperator.LESS_THAN_OR_EQUALS.value: Operator.LESS_THAN_OR_EQUAL,
|
||||
FilterOperator.IN.value: Operator.IN,
|
||||
FilterOperator.NOT_IN.value: Operator.NOT_IN,
|
||||
FilterOperator.LIKE.value: Operator.LIKE,
|
||||
FilterOperator.NOT_LIKE.value: Operator.NOT_LIKE,
|
||||
FilterOperator.IS_NULL.value: Operator.IS_NULL,
|
||||
FilterOperator.IS_NOT_NULL.value: Operator.IS_NOT_NULL,
|
||||
}
|
||||
|
||||
operator = operator_mapping.get(operator_str)
|
||||
if not operator:
|
||||
# Unknown operator - create adhoc filter
|
||||
return None
|
||||
|
||||
return Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=dimension,
|
||||
operator=operator,
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
def _get_order_from_query_object(
|
||||
query_object: ValidatedQueryObject,
|
||||
all_metrics: dict[str, Metric],
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> list[OrderTuple]:
|
||||
order: list[OrderTuple] = []
|
||||
for element, ascending in query_object.orderby:
|
||||
direction = OrderDirection.ASC if ascending else OrderDirection.DESC
|
||||
|
||||
# adhoc
|
||||
if isinstance(element, dict):
|
||||
if element["sqlExpression"] is not None:
|
||||
order.append(
|
||||
(
|
||||
AdhocExpression(
|
||||
id=element["label"] or element["sqlExpression"],
|
||||
definition=element["sqlExpression"],
|
||||
),
|
||||
direction,
|
||||
)
|
||||
)
|
||||
elif element in all_dimensions:
|
||||
order.append((all_dimensions[element], direction))
|
||||
elif element in all_metrics:
|
||||
order.append((all_metrics[element], direction))
|
||||
|
||||
return order
|
||||
|
||||
|
||||
def _get_group_limit_from_query_object(
|
||||
query_object: ValidatedQueryObject,
|
||||
all_metrics: dict[str, Metric],
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> GroupLimit | None:
|
||||
# no limit
|
||||
if query_object.series_limit == 0 or not query_object.columns:
|
||||
return None
|
||||
|
||||
dimensions = [all_dimensions[dim_id] for dim_id in query_object.series_columns]
|
||||
top = query_object.series_limit
|
||||
metric = (
|
||||
all_metrics[query_object.series_limit_metric]
|
||||
if query_object.series_limit_metric
|
||||
else None
|
||||
)
|
||||
direction = OrderDirection.DESC if query_object.order_desc else OrderDirection.ASC
|
||||
group_others = query_object.group_others_when_limit_reached
|
||||
|
||||
# Check if we need separate filters for the group limit subquery
|
||||
# This happens when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm
|
||||
group_limit_filters = _get_group_limit_filters(query_object, all_dimensions)
|
||||
|
||||
return GroupLimit(
|
||||
dimensions=dimensions,
|
||||
top=top,
|
||||
metric=metric,
|
||||
direction=direction,
|
||||
group_others=group_others,
|
||||
filters=group_limit_filters,
|
||||
)
|
||||
|
||||
|
||||
def _get_group_limit_filters(
|
||||
query_object: ValidatedQueryObject,
|
||||
all_dimensions: dict[str, Dimension],
|
||||
) -> set[Filter | AdhocFilter] | None:
|
||||
"""
|
||||
Get separate filters for the group limit subquery if needed.
|
||||
|
||||
This is used when inner_from_dttm/inner_to_dttm differ from from_dttm/to_dttm,
|
||||
which happens during time comparison queries. The group limit subquery may need
|
||||
different time bounds to determine the top N groups.
|
||||
|
||||
Returns None if the group limit should use the same filters as the main query.
|
||||
"""
|
||||
# Check if inner time bounds are explicitly set and differ from outer bounds
|
||||
if (
|
||||
query_object.inner_from_dttm is None
|
||||
or query_object.inner_to_dttm is None
|
||||
or (
|
||||
query_object.inner_from_dttm == query_object.from_dttm
|
||||
and query_object.inner_to_dttm == query_object.to_dttm
|
||||
)
|
||||
):
|
||||
# No separate bounds needed - use the same filters as the main query
|
||||
return None
|
||||
|
||||
# Create separate filters for the group limit subquery
|
||||
filters: set[Filter | AdhocFilter] = set()
|
||||
|
||||
# Add time range filter using inner bounds
|
||||
if query_object.granularity:
|
||||
time_dimension = all_dimensions.get(query_object.granularity)
|
||||
if (
|
||||
time_dimension
|
||||
and query_object.inner_from_dttm
|
||||
and query_object.inner_to_dttm
|
||||
):
|
||||
filters.update(
|
||||
{
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.GREATER_THAN_OR_EQUAL,
|
||||
value=query_object.inner_from_dttm,
|
||||
),
|
||||
Filter(
|
||||
type=PredicateType.WHERE,
|
||||
column=time_dimension,
|
||||
operator=Operator.LESS_THAN,
|
||||
value=query_object.inner_to_dttm,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Add fetch values predicate if present
|
||||
if (
|
||||
query_object.apply_fetch_values_predicate
|
||||
and query_object.datasource.fetch_values_predicate
|
||||
):
|
||||
filters.add(
|
||||
AdhocFilter(
|
||||
type=PredicateType.WHERE,
|
||||
definition=query_object.datasource.fetch_values_predicate,
|
||||
)
|
||||
)
|
||||
|
||||
# Add filters from query_object.extras (WHERE and HAVING clauses)
|
||||
extras_filters = _get_filters_from_extras(query_object.extras)
|
||||
filters.update(extras_filters)
|
||||
|
||||
# Add all other non-temporal filters from query_object.filter
|
||||
for filter_ in query_object.filter:
|
||||
# Skip temporal range filters - we're using inner bounds instead
|
||||
if filter_.get("op") == FilterOperator.TEMPORAL_RANGE.value:
|
||||
continue
|
||||
|
||||
converted_filter = _convert_query_object_filter(filter_, all_dimensions)
|
||||
if converted_filter:
|
||||
filters.add(converted_filter)
|
||||
|
||||
return filters if filters else None
|
||||
|
||||
|
||||
def _convert_time_grain(time_grain: str) -> TimeGrain | DateGrain | None:
|
||||
"""
|
||||
Convert a time grain string from the query object to a TimeGrain or DateGrain enum.
|
||||
"""
|
||||
if time_grain in TimeGrain.__members__:
|
||||
return TimeGrain[time_grain]
|
||||
|
||||
if time_grain in DateGrain.__members__:
|
||||
return DateGrain[time_grain]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_query_object(
|
||||
query_object: QueryObject,
|
||||
) -> TypeGuard[ValidatedQueryObject]:
|
||||
"""
|
||||
Validate that the `QueryObject` is compatible with the `SemanticView`.
|
||||
|
||||
If some semantic view implementation supports these features we should add an
|
||||
attribute to the `SemanticViewImplementation` to indicate support for them.
|
||||
"""
|
||||
if not query_object.datasource:
|
||||
return False
|
||||
|
||||
query_object = cast(ValidatedQueryObject, query_object)
|
||||
|
||||
_validate_metrics(query_object)
|
||||
_validate_dimensions(query_object)
|
||||
_validate_filters(query_object)
|
||||
_validate_granularity(query_object)
|
||||
_validate_group_limit(query_object)
|
||||
_validate_orderby(query_object)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_metrics(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure metrics are defined in the semantic view.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
if any(not isinstance(metric, str) for metric in (query_object.metrics or [])):
|
||||
raise ValueError("Adhoc metrics are not supported in Semantic Views.")
|
||||
|
||||
metric_names = {metric.name for metric in semantic_view.metrics}
|
||||
if not set(query_object.metrics or []) <= metric_names:
|
||||
raise ValueError("All metrics must be defined in the Semantic View.")
|
||||
|
||||
|
||||
def _validate_dimensions(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure all dimensions are defined in the semantic view.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
if any(not isinstance(column, str) for column in query_object.columns):
|
||||
raise ValueError("Adhoc dimensions are not supported in Semantic Views.")
|
||||
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
if not set(query_object.columns) <= dimension_names:
|
||||
raise ValueError("All dimensions must be defined in the Semantic View.")
|
||||
|
||||
|
||||
def _validate_filters(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure all filters are valid.
|
||||
"""
|
||||
for filter_ in query_object.filter:
|
||||
if isinstance(filter_["col"], dict):
|
||||
raise ValueError(
|
||||
"Adhoc columns are not supported in Semantic View filters."
|
||||
)
|
||||
if not filter_.get("op"):
|
||||
raise ValueError("All filters must have an operator defined.")
|
||||
|
||||
|
||||
def _validate_granularity(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Make sure time column and time grain are valid.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
|
||||
if time_column := query_object.granularity:
|
||||
if time_column not in dimension_names:
|
||||
raise ValueError(
|
||||
"The time column must be defined in the Semantic View dimensions."
|
||||
)
|
||||
|
||||
if time_grain := query_object.extras.get("time_grain_sqla"):
|
||||
if not time_column:
|
||||
raise ValueError(
|
||||
"A time column must be specified when a time grain is provided."
|
||||
)
|
||||
|
||||
supported_time_grains = {
|
||||
dimension.grain
|
||||
for dimension in semantic_view.dimensions
|
||||
if dimension.name == time_column and dimension.grain
|
||||
}
|
||||
if _convert_time_grain(time_grain) not in supported_time_grains:
|
||||
raise ValueError(
|
||||
"The time grain is not supported for the time column in the "
|
||||
"Semantic View."
|
||||
)
|
||||
|
||||
|
||||
def _validate_group_limit(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Validate group limit related features in the query object.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
# no limit
|
||||
if query_object.series_limit == 0:
|
||||
return
|
||||
|
||||
if (
|
||||
query_object.series_columns
|
||||
and SemanticViewFeature.GROUP_LIMIT not in semantic_view.features
|
||||
):
|
||||
raise ValueError("Group limit is not supported in this Semantic View.")
|
||||
|
||||
if any(not isinstance(col, str) for col in query_object.series_columns):
|
||||
raise ValueError("Adhoc dimensions are not supported in series columns.")
|
||||
|
||||
metric_names = {metric.name for metric in semantic_view.metrics}
|
||||
if query_object.series_limit_metric and (
|
||||
not isinstance(query_object.series_limit_metric, str)
|
||||
or query_object.series_limit_metric not in metric_names
|
||||
):
|
||||
raise ValueError(
|
||||
"The series limit metric must be defined in the Semantic View."
|
||||
)
|
||||
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
if not set(query_object.series_columns) <= dimension_names:
|
||||
raise ValueError("All series columns must be defined in the Semantic View.")
|
||||
|
||||
if (
|
||||
query_object.group_others_when_limit_reached
|
||||
and SemanticViewFeature.GROUP_OTHERS not in semantic_view.features
|
||||
):
|
||||
raise ValueError(
|
||||
"Grouping others when limit is reached is not supported in this Semantic "
|
||||
"View."
|
||||
)
|
||||
|
||||
|
||||
def _validate_orderby(query_object: ValidatedQueryObject) -> None:
|
||||
"""
|
||||
Validate order by elements in the query object.
|
||||
"""
|
||||
semantic_view = query_object.datasource.implementation
|
||||
|
||||
if (
|
||||
any(not isinstance(element, str) for element, _ in query_object.orderby)
|
||||
and SemanticViewFeature.ADHOC_EXPRESSIONS_IN_ORDERBY
|
||||
not in semantic_view.features
|
||||
):
|
||||
raise ValueError(
|
||||
"Adhoc expressions in order by are not supported in this Semantic View."
|
||||
)
|
||||
|
||||
elements = set(query_object.orderby)
|
||||
metric_names = {metric.name for metric in semantic_view.metrics}
|
||||
dimension_names = {dimension.name for dimension in semantic_view.dimensions}
|
||||
if not elements <= metric_names | dimension_names:
|
||||
raise ValueError("All order by elements must be defined in the Semantic View.")
|
||||
@@ -0,0 +1,205 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Semantic layer models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from importlib.metadata import entry_points
|
||||
from typing import Any
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.models.helpers import AuditMixinNullable, QueryResult
|
||||
from superset.semantic_layers.mapper import get_results
|
||||
from superset.semantic_layers.types import (
|
||||
DATE,
|
||||
DATETIME,
|
||||
SemanticLayerImplementation,
|
||||
SemanticViewImplementation,
|
||||
TIME,
|
||||
)
|
||||
from superset.superset_typing import QueryObjectDict
|
||||
from superset.utils import core as utils
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ColumnMetadata:
|
||||
column_name: str
|
||||
type: str
|
||||
is_dttm: bool
|
||||
|
||||
|
||||
class SemanticLayer(AuditMixinNullable, Model):
|
||||
"""
|
||||
Semantic layer model.
|
||||
|
||||
A semantic layer provides an abstraction over data sources,
|
||||
allowing users to query data through a semantic interface.
|
||||
"""
|
||||
|
||||
__tablename__ = "semantic_layers"
|
||||
|
||||
uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# Core fields
|
||||
name = Column(String(250), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
type = Column(String(250), nullable=False)
|
||||
|
||||
# XXX: encrypt at rest
|
||||
configuration = Column(utils.MediumText(), default="{}")
|
||||
cache_timeout = Column(Integer, nullable=True)
|
||||
|
||||
# Semantic views relationship
|
||||
semantic_views: list[SemanticView] = relationship(
|
||||
"SemanticView",
|
||||
back_populates="semantic_layer",
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.name or str(self.uuid)
|
||||
|
||||
@property
|
||||
def implementation(
|
||||
self,
|
||||
) -> SemanticLayerImplementation[Any, SemanticViewImplementation]:
|
||||
"""
|
||||
Return semantic layer implementation.
|
||||
"""
|
||||
entry_point = next(
|
||||
iter(
|
||||
entry_points(
|
||||
group="superset.semantic_layers",
|
||||
name=self.type,
|
||||
)
|
||||
)
|
||||
)
|
||||
implementation_class = entry_point.load()
|
||||
|
||||
if not issubclass(implementation_class, SemanticLayerImplementation):
|
||||
raise TypeError(
|
||||
f"Entry point for semantic layer type '{self.type}' "
|
||||
"must be a subclass of SemanticLayerImplementation"
|
||||
)
|
||||
|
||||
# XXX store in self._implementation
|
||||
return implementation_class.from_configuration(self.configuration)
|
||||
|
||||
|
||||
class SemanticView(AuditMixinNullable, Model):
|
||||
"""
|
||||
Semantic view model.
|
||||
|
||||
A semantic view represents a queryable view within a semantic layer.
|
||||
"""
|
||||
|
||||
__tablename__ = "semantic_views"
|
||||
|
||||
uuid = Column(UUIDType(binary=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
# Core fields
|
||||
name = Column(String(250), nullable=False)
|
||||
|
||||
# XXX: encrypt at rest
|
||||
configuration = Column(utils.MediumText(), default="{}")
|
||||
cache_timeout = Column(Integer, nullable=True)
|
||||
|
||||
# Semantic layer relationship
|
||||
semantic_layer_uuid = Column(
|
||||
UUIDType(binary=True),
|
||||
ForeignKey("semantic_layers.uuid", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
semantic_layer: SemanticLayer = relationship(
|
||||
"SemanticLayer",
|
||||
back_populates="semantic_views",
|
||||
foreign_keys=[semantic_layer_uuid],
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.name or str(self.uuid)
|
||||
|
||||
@property
|
||||
def implementation(self) -> SemanticViewImplementation:
|
||||
"""
|
||||
Return semantic view implementation.
|
||||
"""
|
||||
# XXX store in self._implementation
|
||||
return self.semantic_layer.implementation.get_semantic_view(
|
||||
self.name,
|
||||
self.configuration,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Explorable protocol implementation
|
||||
# =========================================================================
|
||||
|
||||
def get_query_result(self, query_object: QueryObject) -> QueryResult:
|
||||
return get_results(query_object)
|
||||
|
||||
def get_query_str(self, query_obj: QueryObjectDict) -> str:
|
||||
return "Not implemented for semantic layers"
|
||||
|
||||
@property
|
||||
def uid(self) -> str:
|
||||
return self.implementation.uid()
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return "semantic_view"
|
||||
|
||||
@property
|
||||
def columns(self) -> list[ColumnMetadata]:
|
||||
return [
|
||||
ColumnMetadata(
|
||||
column_name=dimension.name,
|
||||
type=dimension.type.__name__,
|
||||
is_dttm=dimension.type in {DATE, TIME, DATETIME},
|
||||
)
|
||||
for dimension in self.implementation.dimensions
|
||||
]
|
||||
|
||||
@property
|
||||
def column_names(self) -> list[str]:
|
||||
return [dimension.name for dimension in self.implementation.dimensions]
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(self.uuid),
|
||||
"uid": self.uid,
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"columns": [],
|
||||
"metrics": [],
|
||||
"database": [],
|
||||
"description": self.description,
|
||||
"schema": None,
|
||||
"catalog": None,
|
||||
"cache_timeout": self.cache_timeout,
|
||||
"offset": None, # XXX
|
||||
"owners": [], # XXX
|
||||
"verbose_map": {}, # XXX
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from superset.semantic_layers.snowflake.schemas import SnowflakeConfiguration
|
||||
from superset.semantic_layers.snowflake.semantic_layer import SnowflakeSemanticLayer
|
||||
from superset.semantic_layers.snowflake.semantic_view import SnowflakeSemanticView
|
||||
|
||||
__all__ = [
|
||||
"SnowflakeConfiguration",
|
||||
"SnowflakeSemanticLayer",
|
||||
"SnowflakeSemanticView",
|
||||
]
|
||||
@@ -0,0 +1,130 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator, SecretStr
|
||||
|
||||
|
||||
class UserPasswordAuth(BaseModel):
|
||||
"""
|
||||
Username and password authentication.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(title="Username and password")
|
||||
|
||||
auth_type: Literal["user_password"] = "user_password"
|
||||
username: str = Field(description="The username to authenticate as.")
|
||||
password: SecretStr = Field(
|
||||
description="The password to authenticate with.",
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyAuth(BaseModel):
|
||||
"""
|
||||
Private key authentication.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(title="Private key")
|
||||
|
||||
auth_type: Literal["private_key"] = "private_key"
|
||||
private_key: SecretStr = Field(
|
||||
description="The private key to authenticate with, in PEM format.",
|
||||
repr=False,
|
||||
)
|
||||
private_key_password: SecretStr = Field(
|
||||
description="The password to decrypt the private key with.",
|
||||
repr=False,
|
||||
)
|
||||
|
||||
|
||||
class SnowflakeConfiguration(BaseModel):
|
||||
"""
|
||||
Parameters needed to connect to Snowflake.
|
||||
"""
|
||||
|
||||
# account is the only required parameter
|
||||
account_identifier: str = Field(
|
||||
description="The Snowflake account identifier.",
|
||||
json_schema_extra={"examples": ["abc12345"]},
|
||||
)
|
||||
|
||||
role: str | None = Field(
|
||||
default=None,
|
||||
description="The default role to use.",
|
||||
json_schema_extra={"examples": ["myrole"]},
|
||||
)
|
||||
warehouse: str | None = Field(
|
||||
default=None,
|
||||
description="The default warehouse to use.",
|
||||
json_schema_extra={"examples": ["testwh"]},
|
||||
)
|
||||
|
||||
auth: Union[UserPasswordAuth, PrivateKeyAuth] = Field(
|
||||
discriminator="auth_type",
|
||||
description="Authentication method",
|
||||
)
|
||||
|
||||
# database and schema can be optionally provided; if not provided the user
|
||||
# will be able to browse databases/schemas
|
||||
database: str | None = Field(
|
||||
default=None,
|
||||
description="The default database to use.",
|
||||
json_schema_extra={
|
||||
"examples": ["testdb"],
|
||||
"x-dynamic": True,
|
||||
"x-dependsOn": ["account_identifier", "auth"],
|
||||
},
|
||||
)
|
||||
allow_changing_database: bool = Field(
|
||||
default=False,
|
||||
description="Allow changing the default database.",
|
||||
)
|
||||
schema_: str | None = Field(
|
||||
default=None,
|
||||
description="The default schema to use.",
|
||||
json_schema_extra={
|
||||
"examples": ["public"],
|
||||
"x-dynamic": True,
|
||||
"x-dependsOn": ["account_identifier", "auth", "database"],
|
||||
},
|
||||
# `schema` is an attribute of `BaseModel` so it needs to be aliased
|
||||
alias="schema",
|
||||
)
|
||||
allow_changing_schema: bool = Field(
|
||||
default=False,
|
||||
description="Allow changing the default schema.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_database_schema_settings(self) -> SnowflakeConfiguration:
|
||||
"""
|
||||
Validate that if database or schema is not specified, the corresponding
|
||||
allow_changing flag must be true.
|
||||
"""
|
||||
if not self.database and not self.allow_changing_database:
|
||||
raise ValueError(
|
||||
"If no database is specified, allow_changing_database must be true"
|
||||
)
|
||||
if not self.schema_ and not self.allow_changing_schema:
|
||||
raise ValueError(
|
||||
"If no schema is specified, allow_changing_schema must be true"
|
||||
)
|
||||
return self
|
||||
@@ -0,0 +1,236 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Any, Literal, TYPE_CHECKING
|
||||
|
||||
from pydantic import create_model, Field
|
||||
from snowflake.connector import connect
|
||||
from snowflake.connector.connection import SnowflakeConnection
|
||||
|
||||
from superset.semantic_layers.snowflake.schemas import SnowflakeConfiguration
|
||||
from superset.semantic_layers.snowflake.utils import get_connection_parameters
|
||||
from superset.semantic_layers.types import (
|
||||
SemanticLayerImplementation,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.semantic_layers.snowflake.semantic_view import SnowflakeSemanticView
|
||||
|
||||
|
||||
class SnowflakeSemanticLayer(
|
||||
SemanticLayerImplementation[SnowflakeConfiguration, SnowflakeSemanticView]
|
||||
):
|
||||
id = "snowflake"
|
||||
name = "Snowflake Semantic Layer"
|
||||
description = "Connect to semantic views stored in Snowflake."
|
||||
|
||||
@classmethod
|
||||
def from_configuration(
|
||||
cls,
|
||||
configuration: dict[str, Any],
|
||||
) -> SnowflakeSemanticLayer:
|
||||
"""
|
||||
Create a SnowflakeSemanticLayer from a configuration dictionary.
|
||||
"""
|
||||
config = SnowflakeConfiguration.model_validate(configuration)
|
||||
return cls(config)
|
||||
|
||||
@classmethod
|
||||
def get_configuration_schema(
|
||||
cls,
|
||||
configuration: SnowflakeConfiguration | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get the JSON schema for the configuration needed to add the semantic layer.
|
||||
|
||||
A partial configuration can be sent to improve the schema. For example,
|
||||
providing account and auth will allow the schema to provide a list of
|
||||
databases; providing a database will allow the schema to provide a list of
|
||||
schemas.
|
||||
|
||||
Note that database and schema can both be left empty when the semantic layer is
|
||||
added to Superset; the user will then have to provide them when loading
|
||||
semantic views.
|
||||
"""
|
||||
schema = SnowflakeConfiguration.model_json_schema()
|
||||
properties = schema["properties"]
|
||||
|
||||
if configuration is None:
|
||||
# set these to empty; they will be populated when a partial configuration is
|
||||
# passed
|
||||
properties["database"]["enum"] = []
|
||||
properties["schema"]["enum"] = []
|
||||
|
||||
return schema
|
||||
|
||||
connection_parameters = get_connection_parameters(configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
if all(
|
||||
getattr(configuration, dependency)
|
||||
for dependency in properties["database"].get("x-dependsOn", [])
|
||||
):
|
||||
options = cls._fetch_databases(connection)
|
||||
properties["database"]["enum"] = list(options)
|
||||
|
||||
if (
|
||||
all(
|
||||
getattr(configuration, dependency)
|
||||
for dependency in properties["schema"].get("x-dependsOn", [])
|
||||
)
|
||||
and configuration.database
|
||||
):
|
||||
options = cls._fetch_schemas(connection, configuration.database)
|
||||
properties["schema"]["enum"] = list(options)
|
||||
|
||||
return schema
|
||||
|
||||
@classmethod
|
||||
def get_runtime_schema(
|
||||
cls,
|
||||
configuration: SnowflakeConfiguration,
|
||||
runtime_data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get the JSON schema for the runtime parameters needed to load semantic views.
|
||||
|
||||
The schema can be enriched with actual values when `runtime_data` is provided,
|
||||
enabling dynamic schema updates (e.g., populating schema dropdown after
|
||||
database is selected).
|
||||
"""
|
||||
fields: dict[str, tuple[Any, Field]] = {}
|
||||
|
||||
# update configuration with runtime data, for example, to select a schema after
|
||||
# the database has been selected
|
||||
configuration = configuration.model_copy(update=runtime_data)
|
||||
|
||||
connection_parameters = get_connection_parameters(configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
if not configuration.database or configuration.allow_changing_database:
|
||||
options = cls._fetch_databases(connection)
|
||||
fields["database"] = (
|
||||
Literal[*options],
|
||||
Field(description="The default database to use."),
|
||||
)
|
||||
|
||||
if not configuration.schema_ or configuration.allow_changing_schema:
|
||||
if configuration.database:
|
||||
options = cls._fetch_schemas(connection, configuration.database)
|
||||
fields["schema_"] = (
|
||||
Literal[*options],
|
||||
Field(
|
||||
description="The default schema to use.",
|
||||
alias="schema",
|
||||
json_schema_extra=(
|
||||
{
|
||||
"x-dynamic": True,
|
||||
"x-dependsOn": ["database"],
|
||||
}
|
||||
if "database" in fields
|
||||
else {}
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Database not provided yet, add schema as empty
|
||||
# (will be populated dynamically)
|
||||
fields["schema_"] = (
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="The default schema to use.",
|
||||
alias="schema",
|
||||
json_schema_extra={
|
||||
"x-dynamic": True,
|
||||
"x-dependsOn": ["database"],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return create_model("RuntimeParameters", **fields).model_json_schema()
|
||||
|
||||
@classmethod
|
||||
def _fetch_databases(cls, connection: SnowflakeConnection) -> set[str]:
|
||||
"""
|
||||
Fetch the list of databases available in the Snowflake account.
|
||||
|
||||
We use `SHOW DATABASES` instead of querying the information schema since it
|
||||
allows to retrieve the list of databases without having to specify a database
|
||||
when connecting.
|
||||
"""
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("SHOW DATABASES")
|
||||
return {row[1] for row in cursor}
|
||||
|
||||
@classmethod
|
||||
def _fetch_schemas(
|
||||
cls,
|
||||
connection: SnowflakeConnection,
|
||||
database: str | None,
|
||||
) -> set[str]:
|
||||
"""
|
||||
Fetch the list of schemas available in a given database.
|
||||
|
||||
The connection should already have the database set in its context.
|
||||
"""
|
||||
if not database:
|
||||
return set()
|
||||
|
||||
cursor = connection.cursor()
|
||||
query = dedent(
|
||||
"""
|
||||
SELECT SCHEMA_NAME
|
||||
FROM INFORMATION_SCHEMA.SCHEMATA
|
||||
WHERE CATALOG_NAME = ?
|
||||
"""
|
||||
).strip()
|
||||
return {row[0] for row in cursor.execute(query, (database,))}
|
||||
|
||||
def __init__(self, configuration: SnowflakeConfiguration):
|
||||
self.configuration = configuration
|
||||
|
||||
def get_semantic_views(
|
||||
self,
|
||||
runtime_configuration: dict[str, Any],
|
||||
) -> set[SnowflakeSemanticView]:
|
||||
"""
|
||||
Get the semantic views available in the semantic layer.
|
||||
"""
|
||||
# Avoid circular import
|
||||
from superset.semantic_layers.snowflake.semantic_view import (
|
||||
SnowflakeSemanticView,
|
||||
)
|
||||
|
||||
# create a new configuration with the runtime parameters
|
||||
configuration = self.configuration.model_copy(update=runtime_configuration)
|
||||
|
||||
connection_parameters = get_connection_parameters(configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
cursor = connection.cursor()
|
||||
query = dedent(
|
||||
"""
|
||||
SHOW SEMANTIC VIEWS
|
||||
->> SELECT "name" FROM $1;
|
||||
"""
|
||||
).strip()
|
||||
views = {
|
||||
SnowflakeSemanticView(row[0], configuration)
|
||||
for row in cursor.execute(query)
|
||||
}
|
||||
return views
|
||||
@@ -0,0 +1,817 @@
|
||||
# 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.
|
||||
|
||||
# ruff: noqa: S608
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from textwrap import dedent
|
||||
|
||||
from pandas import DataFrame
|
||||
from snowflake.connector import connect, DictCursor
|
||||
from snowflake.sqlalchemy.snowdialect import SnowflakeDialect
|
||||
|
||||
from superset.semantic_layers.snowflake.schemas import SnowflakeConfiguration
|
||||
from superset.semantic_layers.snowflake.utils import (
|
||||
get_connection_parameters,
|
||||
substitute_parameters,
|
||||
validate_order_by,
|
||||
)
|
||||
from superset.semantic_layers.types import (
|
||||
AdhocExpression,
|
||||
AdhocFilter,
|
||||
BINARY,
|
||||
BOOLEAN,
|
||||
DATE,
|
||||
DATETIME,
|
||||
DECIMAL,
|
||||
Dimension,
|
||||
Filter,
|
||||
FilterValues,
|
||||
GroupLimit,
|
||||
INTEGER,
|
||||
Metric,
|
||||
NUMBER,
|
||||
OBJECT,
|
||||
Operator,
|
||||
OrderTuple,
|
||||
PredicateType,
|
||||
SemanticRequest,
|
||||
SemanticResult,
|
||||
SemanticViewFeature,
|
||||
SemanticViewImplementation,
|
||||
STRING,
|
||||
TIME,
|
||||
Type,
|
||||
)
|
||||
|
||||
REQUEST_TYPE = "snowflake"
|
||||
|
||||
|
||||
class SnowflakeSemanticView(SemanticViewImplementation):
|
||||
features = frozenset(
|
||||
{
|
||||
SemanticViewFeature.ADHOC_EXPRESSIONS_IN_ORDERBY,
|
||||
SemanticViewFeature.GROUP_LIMIT,
|
||||
SemanticViewFeature.GROUP_OTHERS,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, name: str, configuration: SnowflakeConfiguration):
|
||||
self.configuration = configuration
|
||||
self.name = name
|
||||
|
||||
self._quote = SnowflakeDialect().identifier_preparer.quote
|
||||
|
||||
self.dimensions = self.get_dimensions()
|
||||
self.metrics = self.get_metrics()
|
||||
|
||||
def uid(self) -> str:
|
||||
return ".".join(
|
||||
self._quote(part)
|
||||
for part in (
|
||||
self.configuration.database,
|
||||
self.configuration.schema_,
|
||||
self.name,
|
||||
)
|
||||
)
|
||||
|
||||
def get_dimensions(self) -> set[Dimension]:
|
||||
"""
|
||||
Get the dimensions defined in the semantic view.
|
||||
|
||||
Even though Snowflake supports `SHOW SEMANTIC DIMENSIONS IN my_semantic_view`,
|
||||
it doesn't return the expression of dimensions, so we use a slightly more
|
||||
complicated query to get all the information we need in one go.
|
||||
"""
|
||||
dimensions: set[Dimension] = set()
|
||||
|
||||
query = dedent(
|
||||
f"""
|
||||
DESC SEMANTIC VIEW {self.uid()}
|
||||
->> SELECT "object_name", "property", "property_value"
|
||||
FROM $1
|
||||
WHERE
|
||||
"object_kind" = 'DIMENSION' AND
|
||||
"property" IN ('COMMENT', 'DATA_TYPE', 'EXPRESSION', 'TABLE');
|
||||
"""
|
||||
).strip()
|
||||
|
||||
connection_parameters = get_connection_parameters(self.configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
cursor = connection.cursor(DictCursor)
|
||||
rows = cursor.execute(query).fetchall()
|
||||
|
||||
for name, group in itertools.groupby(rows, key=lambda x: x["object_name"]):
|
||||
attributes = defaultdict(set)
|
||||
for row in group:
|
||||
attributes[row["property"]].add(row["property_value"])
|
||||
|
||||
table = next(iter(attributes["TABLE"]))
|
||||
id_ = table + "." + name
|
||||
type_ = self._get_type(next(iter(attributes["DATA_TYPE"])))
|
||||
description = next(iter(attributes["COMMENT"]), None)
|
||||
definition = next(iter(attributes["EXPRESSION"]), None)
|
||||
|
||||
dimensions.add(Dimension(id_, name, type_, description, definition))
|
||||
|
||||
return dimensions
|
||||
|
||||
def get_metrics(self) -> set[Metric]:
|
||||
"""
|
||||
Get the metrics defined in the semantic view.
|
||||
"""
|
||||
metrics: set[Metric] = set()
|
||||
|
||||
query = dedent(
|
||||
f"""
|
||||
DESC SEMANTIC VIEW {self.uid()}
|
||||
->> SELECT "object_name", "property", "property_value"
|
||||
FROM $1
|
||||
WHERE
|
||||
"object_kind" = 'METRIC' AND
|
||||
"property" IN ('COMMENT', 'DATA_TYPE', 'EXPRESSION', 'TABLE');
|
||||
"""
|
||||
).strip()
|
||||
|
||||
connection_parameters = get_connection_parameters(self.configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
cursor = connection.cursor(DictCursor)
|
||||
rows = cursor.execute(query).fetchall()
|
||||
|
||||
for name, group in itertools.groupby(rows, key=lambda x: x["object_name"]):
|
||||
attributes = defaultdict(set)
|
||||
for row in group:
|
||||
attributes[row["property"]].add(row["property_value"])
|
||||
|
||||
table = next(iter(attributes["TABLE"]))
|
||||
id_ = table + "." + name
|
||||
type_ = self._get_type(next(iter(attributes["DATA_TYPE"])))
|
||||
description = next(iter(attributes["COMMENT"]), None)
|
||||
definition = next(iter(attributes["EXPRESSION"]), None)
|
||||
|
||||
metrics.add(Metric(id_, name, type_, definition, description))
|
||||
|
||||
return metrics
|
||||
|
||||
def _get_type(self, snowflake_type: str | None) -> type[Type]:
|
||||
"""
|
||||
Return the semantic type corresponding to a Snowflake type.
|
||||
"""
|
||||
if snowflake_type is None:
|
||||
return STRING
|
||||
|
||||
type_map = {
|
||||
STRING: {r"VARCHAR\(\d+\)$", "STRING$", "TEXT$", r"CHAR\(\d+\)$"},
|
||||
INTEGER: {r"NUMBER\(38,\s?0\)$", "INT$", "INTEGER$", "BIGINT$"},
|
||||
DECIMAL: {r"NUMBER\(10,\s?2\)$"},
|
||||
NUMBER: {r"NUMBER\(\d+,\s?\d+\)$", "FLOAT$", "DOUBLE$"},
|
||||
BOOLEAN: {"BOOLEAN$"},
|
||||
DATE: {"DATE$"},
|
||||
DATETIME: {"TIMESTAMP_TZ$", "TIMESTAMP__NTZ$"},
|
||||
TIME: {"TIME$"},
|
||||
OBJECT: {"OBJECT$"},
|
||||
BINARY: {r"BINARY\(\d+\)$", r"VARBINARY\(\d+\)$"},
|
||||
}
|
||||
for semantic_type, patterns in type_map.items():
|
||||
if any(
|
||||
re.match(pattern, snowflake_type, re.IGNORECASE) for pattern in patterns
|
||||
):
|
||||
return semantic_type
|
||||
|
||||
return STRING
|
||||
|
||||
def _build_predicates(
|
||||
self,
|
||||
filters: list[Filter | AdhocFilter],
|
||||
) -> tuple[str, tuple[FilterValues, ...]]:
|
||||
"""
|
||||
Convert a set of filters to a single `AND`ed predicate.
|
||||
|
||||
Caller should check the types of filters beforehand, as this method does not
|
||||
differentiate between `WHERE` and `HAVING` predicates.
|
||||
"""
|
||||
if not filters:
|
||||
return "", ()
|
||||
|
||||
# convert filters predicate with associated parameters; native filters are
|
||||
# already strings, so we keep them as-is
|
||||
unary_operators = {Operator.IS_NULL, Operator.IS_NOT_NULL}
|
||||
predicates: list[str] = []
|
||||
parameters: list[FilterValues] = []
|
||||
for filter_ in filters or set():
|
||||
if isinstance(filter_, AdhocFilter):
|
||||
predicates.append(f"({filter_.definition})")
|
||||
else:
|
||||
predicates.append(f"({self._build_native_filter(filter_)})")
|
||||
if filter_.operator not in unary_operators:
|
||||
parameters.extend(
|
||||
[filter_.value]
|
||||
if not isinstance(filter_.value, (set, frozenset))
|
||||
else filter_.value
|
||||
)
|
||||
|
||||
return " AND ".join(predicates), tuple(parameters)
|
||||
|
||||
def get_values(
|
||||
self,
|
||||
dimension: Dimension,
|
||||
filters: set[Filter | AdhocFilter] | None = None,
|
||||
) -> SemanticResult:
|
||||
"""
|
||||
Return distinct values for a dimension.
|
||||
"""
|
||||
where_clause, parameters = self._build_predicates(
|
||||
sorted(
|
||||
filter_
|
||||
for filter_ in (filters or [])
|
||||
if filter_.type == PredicateType.WHERE
|
||||
)
|
||||
)
|
||||
query = dedent(
|
||||
f"""
|
||||
SELECT {self._quote(dimension.name)}
|
||||
FROM SEMANTIC_VIEW(
|
||||
{self.uid()}
|
||||
DIMENSIONS {dimension.id}
|
||||
{"WHERE " + where_clause if where_clause else ""}
|
||||
)
|
||||
"""
|
||||
).strip()
|
||||
connection_parameters = get_connection_parameters(self.configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
df = connection.cursor().execute(query, parameters).fetch_pandas_all()
|
||||
|
||||
return SemanticResult(
|
||||
requests=[
|
||||
SemanticRequest(
|
||||
REQUEST_TYPE,
|
||||
substitute_parameters(query, parameters),
|
||||
)
|
||||
],
|
||||
results=df,
|
||||
)
|
||||
|
||||
def _build_native_filter(self, filter_: Filter) -> str:
|
||||
"""
|
||||
Convert a Filter to a AdhocFilter.
|
||||
"""
|
||||
column = filter_.column
|
||||
operator = filter_.operator
|
||||
value = filter_.value
|
||||
|
||||
column_name = self._quote(column.name)
|
||||
|
||||
# Handle IS NULL and IS NOT NULL operators (no value needed)
|
||||
if operator in {Operator.IS_NULL, Operator.IS_NOT_NULL}:
|
||||
return f"{column_name} {operator.value}"
|
||||
|
||||
# Handle IN and NOT IN operators (set values)
|
||||
if operator in {Operator.IN, Operator.NOT_IN}:
|
||||
parameter_count = len(value) if isinstance(value, (set, frozenset)) else 1
|
||||
formatted_values = ", ".join("?" for _ in range(parameter_count))
|
||||
return f"{column_name} {operator.value} ({formatted_values})"
|
||||
|
||||
return f"{column_name} {operator.value} ?"
|
||||
|
||||
def get_dataframe(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
filters: set[Filter | AdhocFilter] | None = None,
|
||||
order: list[OrderTuple] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
*,
|
||||
group_limit: GroupLimit | None = None,
|
||||
) -> SemanticResult:
|
||||
"""
|
||||
Execute a query and return the results as a Pandas DataFrame.
|
||||
"""
|
||||
if not metrics and not dimensions:
|
||||
return DataFrame()
|
||||
|
||||
query, parameters = self._get_query(
|
||||
metrics,
|
||||
dimensions,
|
||||
filters,
|
||||
order,
|
||||
limit,
|
||||
offset,
|
||||
group_limit,
|
||||
)
|
||||
connection_parameters = get_connection_parameters(self.configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
df = connection.cursor().execute(query, parameters).fetch_pandas_all()
|
||||
|
||||
return SemanticResult(
|
||||
requests=[
|
||||
SemanticRequest(
|
||||
REQUEST_TYPE,
|
||||
substitute_parameters(query, parameters),
|
||||
)
|
||||
],
|
||||
results=df,
|
||||
)
|
||||
|
||||
def get_row_count(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
filters: set[Filter | AdhocFilter] | None = None,
|
||||
order: list[OrderTuple] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
*,
|
||||
group_limit: GroupLimit | None = None,
|
||||
) -> SemanticResult:
|
||||
"""
|
||||
Execute a query and return the number of rows the result would have.
|
||||
"""
|
||||
if not metrics and not dimensions:
|
||||
return SemanticResult(
|
||||
requests=[],
|
||||
results=DataFrame([[0]], columns=["COUNT"]),
|
||||
)
|
||||
|
||||
query, parameters = self._get_query(
|
||||
metrics,
|
||||
dimensions,
|
||||
filters,
|
||||
order,
|
||||
limit,
|
||||
offset,
|
||||
group_limit,
|
||||
)
|
||||
query = f"SELECT COUNT(*) FROM ({query}) AS subquery"
|
||||
connection_parameters = get_connection_parameters(self.configuration)
|
||||
with connect(**connection_parameters) as connection:
|
||||
df = connection.cursor().execute(query, parameters).fechone()[0]
|
||||
|
||||
return SemanticResult(
|
||||
requests=[
|
||||
SemanticRequest(
|
||||
REQUEST_TYPE,
|
||||
substitute_parameters(query, parameters),
|
||||
)
|
||||
],
|
||||
results=df,
|
||||
)
|
||||
|
||||
def _get_query(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
filters: set[Filter | AdhocFilter] | None = None,
|
||||
order: list[OrderTuple] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
group_limit: GroupLimit | None = None,
|
||||
) -> tuple[str, tuple[FilterValues, ...]]:
|
||||
"""
|
||||
Build a query to fetch data from the semantic view.
|
||||
|
||||
This also returns the parameters need to run `cursor.execute()`, passed
|
||||
separately to prevent SQL injection.
|
||||
"""
|
||||
if limit is None and offset is not None:
|
||||
raise ValueError("Offset cannot be set without limit")
|
||||
|
||||
filters = filters or set()
|
||||
where_clause, where_parameters = self._build_predicates(
|
||||
sorted(
|
||||
filter_ for filter_ in filters if filter_.type == PredicateType.WHERE
|
||||
)
|
||||
)
|
||||
# having clauses are not supported, since there's no GROUP BY
|
||||
if any(filter_.type == PredicateType.HAVING for filter_ in filters):
|
||||
raise ValueError("HAVING filters are not supported")
|
||||
|
||||
if group_limit:
|
||||
query, cte_parameters = self._build_query_with_group_limit(
|
||||
metrics,
|
||||
dimensions,
|
||||
where_clause,
|
||||
order,
|
||||
limit,
|
||||
offset,
|
||||
group_limit,
|
||||
)
|
||||
# Combine parameters: CTE params first, then main query params
|
||||
all_parameters = cte_parameters + where_parameters
|
||||
else:
|
||||
query = self._build_simple_query(
|
||||
metrics,
|
||||
dimensions,
|
||||
where_clause,
|
||||
order,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
all_parameters = where_parameters
|
||||
|
||||
return query, all_parameters
|
||||
|
||||
def _alias_element(self, element: Metric | Dimension) -> str:
|
||||
"""
|
||||
Generate an aliased column expression for a metric or dimension.
|
||||
"""
|
||||
return f"{element.id} AS {self._quote(element.id)}"
|
||||
|
||||
def _build_order_clause(
|
||||
self,
|
||||
order: list[OrderTuple] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the ORDER BY clause from a list of (element, direction) tuples.
|
||||
|
||||
Note that for adhoc expressions, Superset will still add `ASC` or `DESC` to the
|
||||
end, which means adhoc expressions can contain multiple columns as long as the
|
||||
last one has no direction specified.
|
||||
|
||||
This is fine:
|
||||
|
||||
gender ASC, COUNT(*)
|
||||
|
||||
But this is not
|
||||
|
||||
gender ASC, COUNT(*) DESC
|
||||
|
||||
The latter will produce a query that looks like this:
|
||||
|
||||
... ORDER BY gender ASC, COUNT(*) DESC DESC
|
||||
|
||||
"""
|
||||
if not order:
|
||||
return ""
|
||||
|
||||
def build_element(element: Metric | Dimension | AdhocExpression) -> str:
|
||||
if isinstance(element, AdhocExpression):
|
||||
validate_order_by(element.definition)
|
||||
return element.definition
|
||||
return self._quote(element.id)
|
||||
|
||||
return ", ".join(
|
||||
f"{build_element(element)} {direction.value}"
|
||||
for element, direction in order
|
||||
)
|
||||
|
||||
def _build_simple_query(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
where_clause: str,
|
||||
order: list[OrderTuple] | None,
|
||||
limit: int | None,
|
||||
offset: int | None,
|
||||
) -> str:
|
||||
"""
|
||||
Build a query without group limiting.
|
||||
"""
|
||||
dimension_arguments = ", ".join(
|
||||
self._alias_element(dimension) for dimension in dimensions
|
||||
)
|
||||
metric_arguments = ", ".join(self._alias_element(metric) for metric in metrics)
|
||||
order_clause = self._build_order_clause(order)
|
||||
|
||||
return dedent(
|
||||
f"""
|
||||
SELECT * FROM SEMANTIC_VIEW(
|
||||
{self.uid()}
|
||||
{"DIMENSIONS " + dimension_arguments if dimension_arguments else ""}
|
||||
{"METRICS " + metric_arguments if metric_arguments else ""}
|
||||
{"WHERE " + where_clause if where_clause else ""}
|
||||
)
|
||||
{"ORDER BY " + order_clause if order_clause else ""}
|
||||
{"LIMIT " + str(limit) if limit is not None else ""}
|
||||
{"OFFSET " + str(offset) if offset is not None else ""}
|
||||
"""
|
||||
).strip()
|
||||
|
||||
def _build_top_groups_cte(
|
||||
self,
|
||||
group_limit: GroupLimit,
|
||||
where_clause: str,
|
||||
) -> tuple[str, tuple[FilterValues, ...]]:
|
||||
"""
|
||||
Build a CTE that finds the top N combinations of limited dimensions.
|
||||
|
||||
If group_limit.filters is set, it uses those filters instead of the main
|
||||
query's where clause. This allows using different time bounds for finding top
|
||||
groups vs showing data.
|
||||
|
||||
Returns:
|
||||
Tuple of (CTE SQL, parameters for the CTE)
|
||||
"""
|
||||
limited_dimension_arguments = ", ".join(
|
||||
self._alias_element(dimension) for dimension in group_limit.dimensions
|
||||
)
|
||||
limited_dimension_names = ", ".join(
|
||||
self._quote(dimension.id) for dimension in group_limit.dimensions
|
||||
)
|
||||
|
||||
# Use separate filters for group limit if provided (Option 2)
|
||||
# Otherwise use the same filters as the main query (Option 1)
|
||||
if group_limit.filters is not None:
|
||||
group_where_clause, group_where_params = self._build_predicates(
|
||||
sorted(
|
||||
filter_
|
||||
for filter_ in group_limit.filters
|
||||
if filter_.type == PredicateType.WHERE
|
||||
)
|
||||
)
|
||||
if any(
|
||||
filter_.type == PredicateType.HAVING for filter_ in group_limit.filters
|
||||
):
|
||||
raise ValueError(
|
||||
"HAVING filters are not supported in group limit filters"
|
||||
)
|
||||
cte_params = group_where_params
|
||||
else:
|
||||
group_where_clause = where_clause
|
||||
cte_params = () # No additional params - using main query params
|
||||
|
||||
# Build METRICS clause and ORDER BY based on whether metric is provided
|
||||
if group_limit.metric is not None:
|
||||
metrics_clause = (
|
||||
f"METRICS {group_limit.metric.id}"
|
||||
f" AS {self._quote(group_limit.metric.id)}"
|
||||
)
|
||||
order_by_clause = (
|
||||
f"{self._quote(group_limit.metric.id)} {group_limit.direction.value}"
|
||||
)
|
||||
else:
|
||||
# No metric provided - order by first dimension
|
||||
metrics_clause = ""
|
||||
order_by_clause = (
|
||||
f"{self._quote(group_limit.dimensions[0].id)} "
|
||||
f"{group_limit.direction.value}"
|
||||
)
|
||||
|
||||
# Build SEMANTIC_VIEW arguments
|
||||
semantic_view_args = [
|
||||
f"DIMENSIONS {limited_dimension_arguments}",
|
||||
]
|
||||
if metrics_clause:
|
||||
semantic_view_args.append(metrics_clause)
|
||||
if group_where_clause:
|
||||
semantic_view_args.append(f"WHERE {group_where_clause}")
|
||||
|
||||
semantic_view_args_str = "\n ".join(semantic_view_args)
|
||||
|
||||
# Add trailing blank line if there's no WHERE clause
|
||||
# This matches the original template behavior
|
||||
if not group_where_clause:
|
||||
semantic_view_args_str += "\n"
|
||||
|
||||
cte_sql = dedent(
|
||||
f"""
|
||||
WITH top_groups AS (
|
||||
SELECT {limited_dimension_names}
|
||||
FROM SEMANTIC_VIEW(
|
||||
{self.uid()}
|
||||
{semantic_view_args_str}
|
||||
)
|
||||
ORDER BY
|
||||
{order_by_clause}
|
||||
LIMIT {group_limit.top}
|
||||
)
|
||||
"""
|
||||
).strip()
|
||||
|
||||
return cte_sql, cte_params
|
||||
|
||||
def _build_group_filter(self, group_limit: GroupLimit) -> str:
|
||||
"""
|
||||
Build a WHERE filter that restricts results to top N groups.
|
||||
"""
|
||||
if len(group_limit.dimensions) == 1:
|
||||
dimension_id = self._quote(group_limit.dimensions[0].id)
|
||||
return f"{dimension_id} IN (SELECT {dimension_id} FROM top_groups)"
|
||||
|
||||
# Multi-column IN clause
|
||||
dimension_tuple = ", ".join(
|
||||
self._quote(dim.id) for dim in group_limit.dimensions
|
||||
)
|
||||
return f"({dimension_tuple}) IN (SELECT {dimension_tuple} FROM top_groups)"
|
||||
|
||||
def _build_case_expression(
|
||||
self,
|
||||
dimension: Dimension,
|
||||
group_condition: str,
|
||||
) -> str:
|
||||
"""
|
||||
Build a CASE expression that replaces non-top values with 'Other'.
|
||||
|
||||
Args:
|
||||
dimension: The dimension to build the CASE for
|
||||
group_condition: The condition to check if value is in top groups
|
||||
(e.g., "staff_id IN (SELECT staff_id FROM top_groups)")
|
||||
|
||||
Returns:
|
||||
SQL CASE expression
|
||||
"""
|
||||
dimension_id = self._quote(dimension.id)
|
||||
return f"""CASE
|
||||
WHEN {group_condition} THEN {dimension_id}
|
||||
ELSE CAST('Other' AS VARCHAR)
|
||||
END"""
|
||||
|
||||
def _build_query_with_others(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
where_clause: str,
|
||||
order: list[OrderTuple] | None,
|
||||
limit: int | None,
|
||||
offset: int | None,
|
||||
group_limit: GroupLimit,
|
||||
) -> tuple[str, tuple[FilterValues, ...]]:
|
||||
"""
|
||||
Build a query that groups non-top N values as 'Other'.
|
||||
|
||||
This uses a two-stage approach:
|
||||
1. CTE to find top N groups
|
||||
2. Subquery with CASE expressions to replace non-top values with 'Other'
|
||||
3. Outer query to re-aggregate with the new grouping
|
||||
|
||||
Returns:
|
||||
Tuple of (SQL query, CTE parameters)
|
||||
"""
|
||||
top_groups_cte, cte_params = self._build_top_groups_cte(
|
||||
group_limit,
|
||||
where_clause,
|
||||
)
|
||||
|
||||
# Determine which dimensions are limited vs non-limited
|
||||
limited_dimension_ids = {dim.id for dim in group_limit.dimensions}
|
||||
non_limited_dimensions = [
|
||||
dim for dim in dimensions if dim.id not in limited_dimension_ids
|
||||
]
|
||||
|
||||
# Build the group condition for CASE expressions
|
||||
if len(group_limit.dimensions) == 1:
|
||||
dimension_id = self._quote(group_limit.dimensions[0].id)
|
||||
group_condition = (
|
||||
f"{dimension_id} IN (SELECT {dimension_id} FROM top_groups)"
|
||||
)
|
||||
else:
|
||||
dimension_tuple = ", ".join(
|
||||
self._quote(dim.id) for dim in group_limit.dimensions
|
||||
)
|
||||
group_condition = (
|
||||
f"({dimension_tuple}) IN (SELECT {dimension_tuple} FROM top_groups)"
|
||||
)
|
||||
|
||||
# Build CASE expressions for limited dimensions
|
||||
case_expressions = []
|
||||
case_expressions_for_groupby = []
|
||||
for dim in group_limit.dimensions:
|
||||
case_expr = self._build_case_expression(dim, group_condition)
|
||||
alias = self._quote(dim.id)
|
||||
case_expressions.append(f"{case_expr} AS {alias}")
|
||||
# Store the full CASE expression for GROUP BY (not just alias)
|
||||
case_expressions_for_groupby.append(case_expr)
|
||||
|
||||
# Build SELECT for non-limited dimensions (pass through)
|
||||
non_limited_selects = [
|
||||
f"{self._quote(dim.id)} AS {self._quote(dim.id)}"
|
||||
for dim in non_limited_dimensions
|
||||
]
|
||||
|
||||
# Build metric aggregations
|
||||
metric_aggregations = [
|
||||
f"SUM({self._quote(metric.id)}) AS {self._quote(metric.id)}"
|
||||
for metric in metrics
|
||||
]
|
||||
|
||||
# Build the subquery that gets raw data from SEMANTIC_VIEW
|
||||
dimension_arguments = ", ".join(
|
||||
self._alias_element(dimension) for dimension in dimensions
|
||||
)
|
||||
metric_arguments = ", ".join(self._alias_element(metric) for metric in metrics)
|
||||
|
||||
subquery = dedent(
|
||||
f"""
|
||||
raw_data AS (
|
||||
SELECT * FROM SEMANTIC_VIEW(
|
||||
{self.uid()}
|
||||
DIMENSIONS {dimension_arguments}
|
||||
METRICS {metric_arguments}
|
||||
{"WHERE " + where_clause if where_clause else ""}
|
||||
)
|
||||
)
|
||||
"""
|
||||
).strip()
|
||||
|
||||
# Build GROUP BY clause (full CASE expressions + non-limited dimensions)
|
||||
# We need to repeat the full CASE expressions, not use aliases, because
|
||||
# Snowflake may interpret the alias as the original column reference
|
||||
group_by_columns = case_expressions_for_groupby + [
|
||||
self._quote(dim.id) for dim in non_limited_dimensions
|
||||
]
|
||||
group_by_clause = ", ".join(group_by_columns)
|
||||
|
||||
# Build final SELECT columns
|
||||
select_columns = case_expressions + non_limited_selects + metric_aggregations
|
||||
select_clause = ",\n ".join(select_columns)
|
||||
|
||||
# Build ORDER BY clause (need to reference the aliased columns)
|
||||
order_clause = self._build_order_clause(order)
|
||||
|
||||
query = dedent(
|
||||
f"""
|
||||
{top_groups_cte},
|
||||
{subquery}
|
||||
SELECT
|
||||
{select_clause}
|
||||
FROM raw_data
|
||||
GROUP BY {group_by_clause}
|
||||
{"ORDER BY " + order_clause if order_clause else ""}
|
||||
{"LIMIT " + str(limit) if limit is not None else ""}
|
||||
{"OFFSET " + str(offset) if offset is not None else ""}
|
||||
"""
|
||||
).strip()
|
||||
|
||||
return query, cte_params
|
||||
|
||||
def _build_query_with_group_limit(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
where_clause: str,
|
||||
order: list[OrderTuple] | None,
|
||||
limit: int | None,
|
||||
offset: int | None,
|
||||
group_limit: GroupLimit,
|
||||
) -> tuple[str, tuple[FilterValues, ...]]:
|
||||
"""
|
||||
Build a query with group limiting (top N groups).
|
||||
|
||||
If group_others is True, groups non-top values as 'Other'.
|
||||
Otherwise, filters to show only top N groups.
|
||||
|
||||
Returns:
|
||||
Tuple of (SQL query, CTE parameters)
|
||||
"""
|
||||
if group_limit.group_others:
|
||||
return self._build_query_with_others(
|
||||
metrics,
|
||||
dimensions,
|
||||
where_clause,
|
||||
order,
|
||||
limit,
|
||||
offset,
|
||||
group_limit,
|
||||
)
|
||||
|
||||
# Standard group limiting: just filter to top N groups
|
||||
# We can't use CTE references inside SEMANTIC_VIEW(), so we wrap it
|
||||
dimension_arguments = ", ".join(
|
||||
self._alias_element(dimension) for dimension in dimensions
|
||||
)
|
||||
metric_arguments = ", ".join(self._alias_element(metric) for metric in metrics)
|
||||
order_clause = self._build_order_clause(order)
|
||||
|
||||
top_groups_cte, cte_params = self._build_top_groups_cte(
|
||||
group_limit,
|
||||
where_clause,
|
||||
)
|
||||
group_filter = self._build_group_filter(group_limit)
|
||||
|
||||
query = dedent(
|
||||
f"""
|
||||
{top_groups_cte}
|
||||
SELECT * FROM SEMANTIC_VIEW(
|
||||
{self.uid()}
|
||||
{"DIMENSIONS " + dimension_arguments if dimension_arguments else ""}
|
||||
{"METRICS " + metric_arguments if metric_arguments else ""}
|
||||
{"WHERE " + where_clause if where_clause else ""}
|
||||
) AS subquery
|
||||
WHERE {group_filter}
|
||||
{"ORDER BY " + order_clause if order_clause else ""}
|
||||
{"LIMIT " + str(limit) if limit is not None else ""}
|
||||
{"OFFSET " + str(offset) if offset is not None else ""}
|
||||
"""
|
||||
).strip()
|
||||
|
||||
return query, cte_params
|
||||
|
||||
__repr__ = uid
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
|
||||
# ruff: noqa: S608
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Sequence
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
from superset.exceptions import SupersetParseError
|
||||
from superset.semantic_layers.snowflake.schemas import (
|
||||
PrivateKeyAuth,
|
||||
SnowflakeConfiguration,
|
||||
UserPasswordAuth,
|
||||
)
|
||||
from superset.sql.parse import SQLStatement
|
||||
|
||||
|
||||
def substitute_parameters(query: str, parameters: Sequence[Any] | None) -> str:
|
||||
"""
|
||||
Substitute parametereters in templated query.
|
||||
|
||||
This is used to convert bind query parameters so that we can return the executed
|
||||
query for logging/auditing purposes. With Snowflake the binding happens on the
|
||||
server, so the only way to get the true executed query would be to query the
|
||||
database, which is innefficient.
|
||||
"""
|
||||
if not parameters:
|
||||
return query
|
||||
|
||||
result = query
|
||||
for parameter in parameters:
|
||||
if parameter is None:
|
||||
replacement = "NULL"
|
||||
elif isinstance(parameter, bool):
|
||||
# Check bool before int/float since bool is a subclass of int
|
||||
replacement = str(parameter).upper()
|
||||
elif isinstance(parameter, (int, float)):
|
||||
replacement = str(parameter)
|
||||
else:
|
||||
# String - escape single quotes
|
||||
quoted = str(parameter).replace("'", "''")
|
||||
replacement = f"'{quoted}'"
|
||||
|
||||
result = result.replace("?", replacement, 1)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def validate_order_by(definition: str) -> None:
|
||||
"""
|
||||
Validate that an ORDER BY expression is safe to use.
|
||||
|
||||
Note that `definition` could contain multiple expressions separated by commas.
|
||||
"""
|
||||
try:
|
||||
# this ensures that we have a single statement, preventing SQL injection via a
|
||||
# semicolon in the order by clause
|
||||
SQLStatement(f"SELECT 1 ORDER BY {definition}", "snowflake")
|
||||
except SupersetParseError as ex:
|
||||
raise ValueError("Invalid ORDER BY expression") from ex
|
||||
|
||||
|
||||
def get_connection_parameters(configuration: SnowflakeConfiguration) -> dict[str, Any]:
|
||||
"""
|
||||
Convert the configuration to connection parameters for the Snowflake connector.
|
||||
"""
|
||||
params = {
|
||||
"account": configuration.account_identifier,
|
||||
"application": "Apache Superset",
|
||||
"paramstyle": "qmark",
|
||||
"insecure_mode": True,
|
||||
}
|
||||
|
||||
if configuration.role:
|
||||
params["role"] = configuration.role
|
||||
if configuration.warehouse:
|
||||
params["warehouse"] = configuration.warehouse
|
||||
if configuration.database:
|
||||
params["database"] = configuration.database
|
||||
if configuration.schema_:
|
||||
params["schema"] = configuration.schema_
|
||||
|
||||
auth = configuration.auth
|
||||
if isinstance(auth, UserPasswordAuth):
|
||||
params["user"] = auth.username
|
||||
params["password"] = auth.password.get_secret_value()
|
||||
elif isinstance(auth, PrivateKeyAuth):
|
||||
pem_private_key = serialization.load_pem_private_key(
|
||||
auth.private_key.get_secret_value().encode(),
|
||||
password=(
|
||||
auth.private_key_password.get_secret_value().encode()
|
||||
if auth.private_key_password
|
||||
else None
|
||||
),
|
||||
backend=default_backend(),
|
||||
)
|
||||
params["private_key"] = pem_private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
else:
|
||||
raise ValueError("Unsupported authentication method")
|
||||
|
||||
return params
|
||||
@@ -0,0 +1,443 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from functools import total_ordering
|
||||
from typing import Any, Protocol, runtime_checkable, TypeVar
|
||||
|
||||
from pandas import DataFrame
|
||||
from pydantic import BaseModel
|
||||
|
||||
__all__ = [
|
||||
"BINARY",
|
||||
"BOOLEAN",
|
||||
"DATE",
|
||||
"DATETIME",
|
||||
"DECIMAL",
|
||||
"DateGrain",
|
||||
"Dimension",
|
||||
"INTEGER",
|
||||
"INTERVAL",
|
||||
"NUMBER",
|
||||
"OBJECT",
|
||||
"STRING",
|
||||
"TIME",
|
||||
"TimeGrain",
|
||||
]
|
||||
|
||||
|
||||
class Type:
|
||||
"""
|
||||
Base class for types.
|
||||
"""
|
||||
|
||||
|
||||
class INTEGER(Type):
|
||||
"""
|
||||
Represents an integer type.
|
||||
"""
|
||||
|
||||
|
||||
class NUMBER(Type):
|
||||
"""
|
||||
Represents a number type.
|
||||
"""
|
||||
|
||||
|
||||
class DECIMAL(Type):
|
||||
"""
|
||||
Represents a decimal type.
|
||||
"""
|
||||
|
||||
|
||||
class STRING(Type):
|
||||
"""
|
||||
Represents a string type.
|
||||
"""
|
||||
|
||||
|
||||
class BOOLEAN(Type):
|
||||
"""
|
||||
Represents a boolean type.
|
||||
"""
|
||||
|
||||
|
||||
class DATE(Type):
|
||||
"""
|
||||
Represents a date type.
|
||||
"""
|
||||
|
||||
|
||||
class TIME(Type):
|
||||
"""
|
||||
Represents a time type.
|
||||
"""
|
||||
|
||||
|
||||
class DATETIME(DATE, TIME):
|
||||
"""
|
||||
Represents a datetime type.
|
||||
"""
|
||||
|
||||
|
||||
class INTERVAL(Type):
|
||||
"""
|
||||
Represents an interval type.
|
||||
"""
|
||||
|
||||
|
||||
class OBJECT(Type):
|
||||
"""
|
||||
Represents an object type.
|
||||
"""
|
||||
|
||||
|
||||
class BINARY(Type):
|
||||
"""
|
||||
Represents a binary type.
|
||||
"""
|
||||
|
||||
|
||||
@total_ordering
|
||||
class ComparableEnum(enum.Enum):
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if isinstance(other, enum.Enum):
|
||||
return self.value == other.value
|
||||
return NotImplemented
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
if isinstance(other, enum.Enum):
|
||||
return self.value < other.value
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.__class__, self.name))
|
||||
|
||||
|
||||
class TimeGrain(ComparableEnum):
|
||||
PT1S = timedelta(seconds=1)
|
||||
PT1M = timedelta(minutes=1)
|
||||
PT1H = timedelta(hours=1)
|
||||
|
||||
|
||||
class DateGrain(ComparableEnum):
|
||||
P1D = timedelta(days=1)
|
||||
P1W = timedelta(weeks=1)
|
||||
P1M = timedelta(days=30)
|
||||
P3M = timedelta(days=90)
|
||||
P1Y = timedelta(days=365)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dimension:
|
||||
id: str
|
||||
name: str
|
||||
type: type[Type]
|
||||
|
||||
definition: str | None = None
|
||||
description: str | None = None
|
||||
grain: DateGrain | TimeGrain | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metric:
|
||||
id: str
|
||||
name: str
|
||||
type: type[Type]
|
||||
|
||||
definition: str | None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdhocExpression:
|
||||
id: str
|
||||
definition: str
|
||||
|
||||
|
||||
class Operator(str, enum.Enum):
|
||||
EQUALS = "="
|
||||
NOT_EQUALS = "!="
|
||||
GREATER_THAN = ">"
|
||||
LESS_THAN = "<"
|
||||
GREATER_THAN_OR_EQUAL = ">="
|
||||
LESS_THAN_OR_EQUAL = "<="
|
||||
IN = "IN"
|
||||
NOT_IN = "NOT IN"
|
||||
LIKE = "LIKE"
|
||||
NOT_LIKE = "NOT LIKE"
|
||||
IS_NULL = "IS NULL"
|
||||
IS_NOT_NULL = "IS NOT NULL"
|
||||
|
||||
|
||||
FilterValues = str | int | float | bool | datetime | date | time | timedelta | None
|
||||
|
||||
|
||||
class PredicateType(enum.Enum):
|
||||
WHERE = "WHERE"
|
||||
HAVING = "HAVING"
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class Filter:
|
||||
type: PredicateType
|
||||
column: Dimension | Metric
|
||||
operator: Operator
|
||||
value: FilterValues | set[FilterValues]
|
||||
|
||||
|
||||
@dataclass(frozen=True, order=True)
|
||||
class AdhocFilter:
|
||||
type: PredicateType
|
||||
definition: str
|
||||
|
||||
|
||||
class OrderDirection(enum.Enum):
|
||||
ASC = "ASC"
|
||||
DESC = "DESC"
|
||||
|
||||
|
||||
OrderTuple = tuple[Metric | Dimension | AdhocExpression, OrderDirection]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GroupLimit:
|
||||
"""
|
||||
Limit query to top/bottom N combinations of specified dimensions.
|
||||
|
||||
The `filters` parameter allows specifying separate filter constraints for the
|
||||
group limit subquery. This is useful when you want to determine the top N groups
|
||||
using different criteria (e.g., a different time range) than the main query.
|
||||
|
||||
For example, you might want to find the top 10 products by sales over the last
|
||||
30 days, but then show daily sales for those products over the last 7 days.
|
||||
"""
|
||||
|
||||
dimensions: list[Dimension]
|
||||
top: int
|
||||
metric: Metric | None
|
||||
direction: OrderDirection = OrderDirection.DESC
|
||||
group_others: bool = False
|
||||
filters: set[Filter | AdhocFilter] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticRequest:
|
||||
"""
|
||||
Represents a request made to obtain semantic results.
|
||||
|
||||
This could be a SQL query, an HTTP request, etc.
|
||||
"""
|
||||
|
||||
type: str
|
||||
definition: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticResult:
|
||||
"""
|
||||
Represents the results of a semantic query.
|
||||
|
||||
This includes any requests (SQL queries, HTTP requests) that were performed in order
|
||||
to obtain the results, in order to help troubleshooting.
|
||||
"""
|
||||
|
||||
requests: list[SemanticRequest]
|
||||
results: DataFrame
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SemanticQuery:
|
||||
"""
|
||||
Represents a semantic query.
|
||||
"""
|
||||
|
||||
metrics: list[Metric]
|
||||
dimensions: list[Dimension]
|
||||
filters: set[Filter | AdhocFilter] | None = None
|
||||
order: list[OrderTuple] | None = None
|
||||
limit: int | None = None
|
||||
offset: int | None = None
|
||||
group_limit: GroupLimit | None = None
|
||||
|
||||
|
||||
class SemanticViewFeature(enum.Enum):
|
||||
"""
|
||||
Custom features supported by semantic layers.
|
||||
"""
|
||||
|
||||
ADHOC_EXPRESSIONS_IN_ORDERBY = "ADHOC_EXPRESSIONS_IN_ORDERBY"
|
||||
GROUP_LIMIT = "GROUP_LIMIT"
|
||||
GROUP_OTHERS = "GROUP_OTHERS"
|
||||
|
||||
|
||||
ConfigT = TypeVar("ConfigT", bound=BaseModel, contravariant=True)
|
||||
SemanticViewT = TypeVar("SemanticViewT", bound="SemanticViewImplementation")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SemanticLayerImplementation(Protocol[ConfigT, SemanticViewT]):
|
||||
"""
|
||||
A protocol for semantic layers.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def from_configuration(
|
||||
cls,
|
||||
configuration: dict[str, Any],
|
||||
) -> SemanticLayerImplementation[ConfigT, SemanticViewT]:
|
||||
"""
|
||||
Create a semantic layer from its configuration.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_configuration_schema(
|
||||
cls,
|
||||
configuration: ConfigT | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get the JSON schema for the configuration needed to add the semantic layer.
|
||||
|
||||
A partial configuration `configuration` can be sent to improve the schema,
|
||||
allowing for progressive validation and better UX. For example, a semantic
|
||||
layer might require:
|
||||
|
||||
- auth information
|
||||
- a database
|
||||
|
||||
If the user provides the auth information, a client can send the partial
|
||||
configuration to this method, and the resulting JSON schema would include
|
||||
the list of databases the user has access to, allowing a dropdown to be
|
||||
populated.
|
||||
|
||||
The Snowflake semantic layer has an example implementation of this method, where
|
||||
database and schema names are populated based on the provided connection info.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_runtime_schema(
|
||||
cls,
|
||||
configuration: ConfigT,
|
||||
runtime_data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get the JSON schema for the runtime parameters needed to load semantic views.
|
||||
|
||||
This returns the schema needed to connect to a semantic view given the
|
||||
configuration for the semantic layer. For example, a semantic layer might
|
||||
be configured by:
|
||||
|
||||
- auth information
|
||||
- an optional database
|
||||
|
||||
If the user does not provide a database when creating the semantic layer, the
|
||||
runtime schema would require the database name to be provided before loading any
|
||||
semantic views. This allows users to create semantic layers that connect to a
|
||||
specific database (or project, account, etc.), or that allow users to select it
|
||||
at query time.
|
||||
|
||||
The Snowflake semantic layer has an example implementation of this method, where
|
||||
database and schema names are required if they were not provided in the initial
|
||||
configuration.
|
||||
"""
|
||||
|
||||
def get_semantic_views(
|
||||
self,
|
||||
runtime_configuration: dict[str, Any],
|
||||
) -> set[SemanticViewT]:
|
||||
"""
|
||||
Get the semantic views available in the semantic layer.
|
||||
|
||||
The runtime configuration can provide information like a given project or
|
||||
schema, used to restrict the semantic views returned.
|
||||
"""
|
||||
|
||||
def get_semantic_view(
|
||||
self,
|
||||
name: str,
|
||||
additional_configuration: dict[str, Any],
|
||||
) -> SemanticViewT:
|
||||
"""
|
||||
Get a specific semantic view by its name and additional configuration.
|
||||
"""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SemanticViewImplementation(Protocol):
|
||||
"""
|
||||
A protocol for semantic views.
|
||||
"""
|
||||
|
||||
features: frozenset[SemanticViewFeature]
|
||||
|
||||
def uid(self) -> str:
|
||||
"""
|
||||
Returns a unique identifier for the semantic view.
|
||||
"""
|
||||
|
||||
def get_dimensions(self) -> set[Dimension]:
|
||||
"""
|
||||
Get the dimensions defined in the semantic view.
|
||||
"""
|
||||
|
||||
def get_metrics(self) -> set[Metric]:
|
||||
"""
|
||||
Get the metrics defined in the semantic view.
|
||||
"""
|
||||
|
||||
def get_values(
|
||||
self,
|
||||
dimension: Dimension,
|
||||
filters: set[Filter | AdhocFilter] | None = None,
|
||||
) -> SemanticResult:
|
||||
"""
|
||||
Return distinct values for a dimension.
|
||||
"""
|
||||
|
||||
def get_dataframe(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
filters: set[Filter | AdhocFilter] | None = None,
|
||||
order: list[OrderTuple] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
*,
|
||||
group_limit: GroupLimit | None = None,
|
||||
) -> SemanticResult:
|
||||
"""
|
||||
Execute a semantic query and return the results as a DataFrame.
|
||||
"""
|
||||
|
||||
def get_row_count(
|
||||
self,
|
||||
metrics: list[Metric],
|
||||
dimensions: list[Dimension],
|
||||
filters: set[Filter | AdhocFilter] | None = None,
|
||||
order: list[OrderTuple] | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
*,
|
||||
group_limit: GroupLimit | None = None,
|
||||
) -> SemanticResult:
|
||||
"""
|
||||
Execute a query and return the number of rows the result would have.
|
||||
"""
|
||||
@@ -751,10 +751,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "اغسطس"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "هامش عنوان المحور"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "موضع عنوان المحور"
|
||||
|
||||
msgid "About"
|
||||
@@ -13452,7 +13452,7 @@ msgstr "اكتب وصفًا لاستعلامك"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "اكتب قالب المقاود لعرض البيانات"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "هامش عنوان المحور X"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13501,7 +13501,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "حدود Y 2"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "هامش عنوان المحور Y"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -780,10 +780,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "AGO"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "MARGE DEL TÍTOL DE L'EIX"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "POSICIÓ DEL TÍTOL DE L'EIX"
|
||||
|
||||
msgid "About"
|
||||
@@ -13231,7 +13231,7 @@ msgstr "Escriu una descripció per la teva consulta"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Escriu una plantilla handlebars per renderitzar les dades"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "MARGE DEL TÍTOL DE L'EIX X"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13279,7 +13279,7 @@ msgstr "XYZ"
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Límits Y 2"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "MARGE DEL TÍTOL DE L'EIX Y"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -794,10 +794,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "AUG"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "ABSTAND DES ACHSENTITELS"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "Y-ACHSE TITEL POSITION"
|
||||
|
||||
msgid "About"
|
||||
@@ -13868,7 +13868,7 @@ msgstr "Beschreibung Ihrer Anfrage"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Handlebars-Template zur Darstellung der Daten verfassen"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "X AXIS TITLE MARGIN"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13917,7 +13917,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Y 2 Grenzen"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "Y-ACHSE TITEL RAND"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -689,10 +689,10 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr ""
|
||||
|
||||
msgid "About"
|
||||
@@ -12368,7 +12368,7 @@ msgstr ""
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -12416,7 +12416,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -689,10 +689,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "AGO"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "MARGEN DEL TÍTULO DEL EJE"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "POSICIÓN DEL TÍTULO DEL EJE"
|
||||
|
||||
msgid "About"
|
||||
@@ -12368,7 +12368,7 @@ msgstr "Escribe una descripción para tu consulta"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Elabora una plantilla de Handlebars para renderizar los datos"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "MARGEN DEL TÍTULO DEL EJE X"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -12416,7 +12416,7 @@ msgstr "XYZ"
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Límites de Y 2"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "MARGEN DEL TÍTULO DEL EJE Y"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -767,10 +767,10 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr "آگوست"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "حاشیه عنوان محور"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "محل عنوان محور"
|
||||
|
||||
msgid "About"
|
||||
@@ -13425,7 +13425,7 @@ msgstr "توضیحی برای کوئری خود بنویسید."
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "یک الگوی هندلبارز برای نمایش دادهها بنویسید."
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "حاشیه عنوان محور ایکس"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13474,7 +13474,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "بازههای Y ۲"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "حاشیه عنوان محور Y"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -852,10 +852,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "AUG"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "MARGE DU TITRE DE L'AXE"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "POSITION DU TITRE DE L'AXE"
|
||||
|
||||
msgid "About"
|
||||
@@ -16095,7 +16095,7 @@ msgstr "Écrire une description pour votre requête"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Écrire un modèle handlebar pour afficher les données"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "MARGE DU TITRE DE L'AXE DES ABCISSES"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -16148,7 +16148,7 @@ msgstr "XYZ"
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Limites ordonnées 2"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "MARGE DU TITRE DE L'AXE DES ORDONNÉES"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -718,11 +718,11 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "Testa la Connessione"
|
||||
|
||||
msgid "About"
|
||||
@@ -13284,7 +13284,7 @@ msgstr ""
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13336,7 +13336,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -725,10 +725,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "8月"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "軸のタイトルの余白"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "軸タイトルの位置"
|
||||
|
||||
msgid "About"
|
||||
@@ -12622,7 +12622,7 @@ msgstr "クエリの説明を書いてください"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "データをレンダリングするハンドルバー テンプレートを作成する"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "X 軸のタイトルマージン"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -12670,7 +12670,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Y 2 バウンド"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "Y 軸のタイトルマージン"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -715,10 +715,10 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr ""
|
||||
|
||||
msgid "About"
|
||||
@@ -13159,7 +13159,7 @@ msgstr ""
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13209,7 +13209,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -695,10 +695,10 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr ""
|
||||
|
||||
msgid "About"
|
||||
@@ -12353,7 +12353,7 @@ msgstr ""
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -12401,7 +12401,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -757,10 +757,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "AUG"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "AXIS TITEL MARGIN"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "AXIS TITEL POSITIE"
|
||||
|
||||
msgid "About"
|
||||
@@ -13691,7 +13691,7 @@ msgstr "Schrijf een omschrijving voor uw query"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Schrijf een handlebars sjabloon om de gegevens weer te geven"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "X AXIS TITEL MARGE"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13740,7 +13740,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Y 2 grenzen"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "Y AXIS TITEL MARGE"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -796,11 +796,11 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "SIE"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "MARGINES TYTUŁU OSI"
|
||||
|
||||
#, fuzzy
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "POZYCJA TYTUŁU OSI"
|
||||
|
||||
msgid "About"
|
||||
@@ -14280,7 +14280,7 @@ msgstr "Napisz opis swojego zapytania"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Napisz szablon handlebars do renderowania danych"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "MARGINES TYTUŁU OSI X"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -14335,7 +14335,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Granice osi Y 2"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "MARGINES TYTUŁU OSI Y"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -726,10 +726,10 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr ""
|
||||
|
||||
msgid "About"
|
||||
@@ -13486,7 +13486,7 @@ msgstr "Escreva uma descrição para sua consulta"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13537,7 +13537,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -769,10 +769,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "AGO"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "MARGEM DO EIXO DO TÍTULO "
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "POSIÇÃO DO EIXO DO TÍTULO"
|
||||
|
||||
msgid "About"
|
||||
@@ -13862,7 +13862,7 @@ msgstr "Escreva uma descrição para sua consulta"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Escreva um modelo de guidão para renderizar os dados"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "MARGEM DO TÍTULO DO EIXO X"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13912,7 +13912,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Y 2 limites"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "MARGEM DO TÍTULO DO EIXO Y"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -799,10 +799,10 @@ msgstr "Асинхронные запросы"
|
||||
msgid "AUG"
|
||||
msgstr "АВГ"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "Отступ заголовка оси"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "Положение заголовка оси"
|
||||
|
||||
msgid "About"
|
||||
@@ -14035,7 +14035,7 @@ msgstr "Заполните описание к вашему запросу"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Напишите шаблон Handlebars для отображения данных"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "Отступ заголовка оси X"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -14089,7 +14089,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Границы оси Y 2"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "Отступ заголовка оси Y"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -693,10 +693,10 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr ""
|
||||
|
||||
msgid "About"
|
||||
@@ -12502,7 +12502,7 @@ msgstr ""
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -12550,7 +12550,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -787,10 +787,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "AVG"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "OBROBA OZNAKE OSI"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "POLOŽAJ OZNAKE OSI"
|
||||
|
||||
msgid "About"
|
||||
@@ -13457,7 +13457,7 @@ msgstr "Dodajte opis vaše poizvedbe"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Napišite Handlebars-predlogo za prikaz podatkov"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "OBROBA NASLOVA X-OSI"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13506,7 +13506,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Meje Y-osi 2"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "OBROBA NASLOVA Y-OSI"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -691,10 +691,10 @@ msgstr ""
|
||||
msgid "AUG"
|
||||
msgstr "AĞU"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr ""
|
||||
|
||||
msgid "About"
|
||||
@@ -12543,7 +12543,7 @@ msgstr ""
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -12591,7 +12591,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -759,10 +759,10 @@ msgstr "AQE"
|
||||
msgid "AUG"
|
||||
msgstr "Серпень"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "ЗАВДАННЯ ВІСІВ"
|
||||
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "Позиція заголовка вісь"
|
||||
|
||||
msgid "About"
|
||||
@@ -13662,7 +13662,7 @@ msgstr "Напишіть опис свого запиту"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr "Напишіть шаблон ручки для надання даних"
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr ""
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13712,7 +13712,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Y 2 межі"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "Y Exis title Margin"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -730,11 +730,11 @@ msgstr "异步执行查询"
|
||||
msgid "AUG"
|
||||
msgstr "八月"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "轴标题边距"
|
||||
|
||||
#, fuzzy
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "轴标题的位置"
|
||||
|
||||
msgid "About"
|
||||
@@ -13364,7 +13364,7 @@ msgstr "为您的查询写一段描述"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "X 轴标题边距"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13416,7 +13416,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Y 界限"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "Y 轴标题边距"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -729,11 +729,11 @@ msgstr "異步執行查詢"
|
||||
msgid "AUG"
|
||||
msgstr "八月"
|
||||
|
||||
msgid "AXIS TITLE MARGIN"
|
||||
msgid "Axis title margin"
|
||||
msgstr "軸標題邊距"
|
||||
|
||||
#, fuzzy
|
||||
msgid "AXIS TITLE POSITION"
|
||||
msgid "Axis title position"
|
||||
msgstr "軸標題的位置"
|
||||
|
||||
msgid "About"
|
||||
@@ -13378,7 +13378,7 @@ msgstr "為您的查詢寫一段描述"
|
||||
msgid "Write a handlebars template to render the data"
|
||||
msgstr ""
|
||||
|
||||
msgid "X AXIS TITLE MARGIN"
|
||||
msgid "X axis title margin"
|
||||
msgstr "X 軸標題邊距"
|
||||
|
||||
msgid "X Axis"
|
||||
@@ -13430,7 +13430,7 @@ msgstr ""
|
||||
msgid "Y 2 bounds"
|
||||
msgstr "Y 界限"
|
||||
|
||||
msgid "Y AXIS TITLE MARGIN"
|
||||
msgid "Y axis title margin"
|
||||
msgstr "Y 軸標題邊距"
|
||||
|
||||
msgid "Y Axis"
|
||||
|
||||
@@ -216,6 +216,13 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
|
||||
return error_messages
|
||||
|
||||
@staticmethod
|
||||
def _get_screenshot(page: Page, element: Locator, element_name: str) -> bytes:
|
||||
if element_name == "standalone":
|
||||
return page.screenshot(full_page=True)
|
||||
else:
|
||||
return element.screenshot()
|
||||
|
||||
def get_screenshot( # pylint: disable=too-many-locals, too-many-statements # noqa: C901
|
||||
self, url: str, element_name: str, user: User
|
||||
) -> bytes | None:
|
||||
@@ -363,11 +370,18 @@ class WebDriverPlaywright(WebDriverProxy):
|
||||
"falling back to standard screenshot"
|
||||
)
|
||||
)
|
||||
img = element.screenshot()
|
||||
img = WebDriverPlaywright._get_screenshot(
|
||||
page, element, element_name
|
||||
)
|
||||
else:
|
||||
img = element.screenshot()
|
||||
img = WebDriverPlaywright._get_screenshot(
|
||||
page, element, element_name
|
||||
)
|
||||
else:
|
||||
img = element.screenshot()
|
||||
img = WebDriverPlaywright._get_screenshot(
|
||||
page, element, element_name
|
||||
)
|
||||
|
||||
except PlaywrightTimeout:
|
||||
# raise again for the finally block, but handled above
|
||||
pass
|
||||
|
||||
+13
-4
@@ -46,7 +46,6 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from werkzeug.utils import safe_join
|
||||
|
||||
from superset import (
|
||||
appbuilder,
|
||||
db,
|
||||
event_logger,
|
||||
is_feature_enabled,
|
||||
@@ -80,6 +79,7 @@ from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.models.user_attributes import UserAttribute
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.tasks.utils import get_current_user
|
||||
from superset.utils import core as utils, json
|
||||
from superset.utils.cache import etag_cache
|
||||
from superset.utils.core import (
|
||||
@@ -108,6 +108,7 @@ from superset.views.utils import (
|
||||
get_form_data,
|
||||
get_viz,
|
||||
loads_request_json,
|
||||
redirect_to_login,
|
||||
sanitize_datasource_data,
|
||||
)
|
||||
from superset.viz import BaseViz
|
||||
@@ -765,13 +766,21 @@ class Superset(BaseSupersetView):
|
||||
dashboard = Dashboard.get(dashboard_id_or_slug)
|
||||
|
||||
if not dashboard:
|
||||
if not get_current_user():
|
||||
return redirect_to_login()
|
||||
abort(404)
|
||||
|
||||
# Redirect anonymous users to login for unpublished dashboards,
|
||||
# in the edge case where a dataset has been shared with public
|
||||
if not get_current_user() and not dashboard.published:
|
||||
return redirect_to_login()
|
||||
|
||||
try:
|
||||
dashboard.raise_for_access()
|
||||
except SupersetSecurityException:
|
||||
# Return 404 to avoid revealing dashboard existence
|
||||
return Response(status=404)
|
||||
if not get_current_user():
|
||||
return redirect_to_login()
|
||||
abort(404)
|
||||
add_extra_log_payload(
|
||||
dashboard_id=dashboard.id,
|
||||
dashboard_version="v2",
|
||||
@@ -882,7 +891,7 @@ class Superset(BaseSupersetView):
|
||||
def welcome(self) -> FlaskResponse:
|
||||
"""Personalized welcome page"""
|
||||
if not g.user or not get_user_id():
|
||||
return redirect(appbuilder.get_url_for_login)
|
||||
return redirect_to_login()
|
||||
|
||||
if welcome_dashboard_id := (
|
||||
db.session.query(UserAttribute.welcome_dashboard_id)
|
||||
|
||||
@@ -25,7 +25,6 @@ from typing import Any, Callable, cast
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
redirect,
|
||||
request,
|
||||
Response,
|
||||
send_file,
|
||||
@@ -34,7 +33,6 @@ from flask_wtf.csrf import CSRFError
|
||||
from sqlalchemy import exc
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from superset import appbuilder
|
||||
from superset.commands.exceptions import CommandException, CommandInvalidError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
@@ -46,6 +44,7 @@ from superset.exceptions import (
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import core as utils, json
|
||||
from superset.utils.log import get_logger_from_status
|
||||
from superset.views.utils import redirect_to_login
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from superset.views.base import BaseSupersetView
|
||||
@@ -153,7 +152,7 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901
|
||||
if request.is_json:
|
||||
return show_http_exception(ex)
|
||||
|
||||
return redirect(appbuilder.get_url_for_login)
|
||||
return redirect_to_login()
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def show_http_exception(ex: HTTPException) -> FlaskResponse:
|
||||
@@ -165,7 +164,11 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901
|
||||
and ex.code in {404, 500}
|
||||
):
|
||||
path = files("superset") / f"static/assets/{ex.code}.html"
|
||||
return send_file(path, max_age=0), ex.code
|
||||
# Try to serve HTML file; fall back to JSON if not built
|
||||
try:
|
||||
return send_file(path, max_age=0), ex.code
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
return json_error_response(
|
||||
[
|
||||
@@ -189,7 +192,11 @@ def set_app_error_handlers(app: Flask) -> None: # noqa: C901
|
||||
|
||||
if "text/html" in request.accept_mimetypes and not app.config["DEBUG"]:
|
||||
path = files("superset") / "static/assets/500.html"
|
||||
return send_file(path, max_age=0), 500
|
||||
# Try to serve HTML file; fall back to JSON if not built
|
||||
try:
|
||||
return send_file(path, max_age=0), 500
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
extra = ex.normalized_messages() if isinstance(ex, CommandInvalidError) else {}
|
||||
return json_error_response(
|
||||
|
||||
+31
-3
@@ -19,16 +19,17 @@ import logging
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, DefaultDict, Optional, Union
|
||||
from urllib import parse
|
||||
|
||||
import msgpack
|
||||
import pyarrow as pa
|
||||
from flask import current_app as app, g, has_request_context, request
|
||||
from flask import current_app as app, g, has_request_context, redirect, request
|
||||
from flask_appbuilder.security.sqla import models as ab_models
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from flask_babel import _
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
|
||||
from superset import dataframe, db, result_set, viz
|
||||
from superset import appbuilder, dataframe, db, result_set, viz
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.daos.datasource import DatasourceDAO
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
@@ -44,7 +45,7 @@ from superset.models.core import Database
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.superset_typing import FormData
|
||||
from superset.superset_typing import FlaskResponse, FormData
|
||||
from superset.utils import json
|
||||
from superset.utils.core import DatasourceType
|
||||
from superset.utils.decorators import stats_timing
|
||||
@@ -58,6 +59,33 @@ if not feature_flag_manager.is_feature_enabled("ENABLE_JAVASCRIPT_CONTROLS"):
|
||||
REJECTED_FORM_DATA_KEYS = ["js_tooltip", "js_onclick_href", "js_data_mutator"]
|
||||
|
||||
|
||||
def redirect_to_login(next_target: str | None = None) -> FlaskResponse:
|
||||
"""Return a redirect response to the login view, preserving target URL.
|
||||
|
||||
When ``next_target`` is ``None`` the current request path (including query
|
||||
string) is used, provided a request context is available. The resulting URL
|
||||
always remains relative, mirroring Flask-AppBuilder expectations.
|
||||
"""
|
||||
|
||||
login_url = appbuilder.get_url_for_login
|
||||
parsed = parse.urlparse(login_url)
|
||||
query = parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||
|
||||
target = next_target
|
||||
if target is None and has_request_context():
|
||||
if request.query_string:
|
||||
target = request.full_path.rstrip("?")
|
||||
else:
|
||||
target = request.path
|
||||
|
||||
if target:
|
||||
query["next"] = [target]
|
||||
|
||||
encoded_query = parse.urlencode(query, doseq=True)
|
||||
redirect_url = parse.urlunparse(parsed._replace(query=encoded_query))
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
def sanitize_datasource_data(datasource_data: dict[str, Any]) -> dict[str, Any]:
|
||||
if datasource_data:
|
||||
datasource_database = datasource_data.get("database")
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"""Unit tests for Superset"""
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from random import random
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from flask import Response, escape, url_for
|
||||
@@ -52,7 +52,7 @@ from tests.integration_tests.fixtures.world_bank_dashboard import (
|
||||
load_world_bank_data, # noqa: F401
|
||||
)
|
||||
|
||||
from .base_tests import SupersetTestCase
|
||||
from .base_tests import DEFAULT_PASSWORD, SupersetTestCase
|
||||
|
||||
|
||||
class TestDashboard(SupersetTestCase):
|
||||
@@ -186,6 +186,72 @@ class TestDashboard(SupersetTestCase):
|
||||
# Cleanup
|
||||
self.revoke_public_access_to_table(table)
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"load_energy_table_with_slice",
|
||||
"load_dashboard",
|
||||
)
|
||||
def test_anonymous_user_redirects_to_login_with_next(self):
|
||||
self.logout()
|
||||
target_path = f"/superset/dashboard/{pytest.hidden_dash_slug}/"
|
||||
|
||||
response = self.client.get(target_path, follow_redirects=False)
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
redirect_location = response.headers["Location"]
|
||||
parsed = urlparse(redirect_location)
|
||||
assert parsed.path.rstrip("/") == "/login"
|
||||
|
||||
next_values = parse_qs(parsed.query).get("next")
|
||||
assert next_values is not None
|
||||
assert next_values[0].endswith(target_path)
|
||||
|
||||
login_target = (
|
||||
f"{parsed.path}{'?' + parsed.query if parsed.query else ''}"
|
||||
if parsed.scheme or parsed.netloc
|
||||
else redirect_location
|
||||
)
|
||||
|
||||
login_response = self.client.post(
|
||||
login_target,
|
||||
data={"username": ADMIN_USERNAME, "password": DEFAULT_PASSWORD},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert login_response.status_code == 302
|
||||
assert login_response.headers["Location"].endswith(target_path)
|
||||
|
||||
target_response: Response = self.client.get(target_path, follow_redirects=False)
|
||||
assert target_response.status_code == 200
|
||||
|
||||
def test_anonymous_user_redirects_to_login_for_missing_dashboard(self):
|
||||
self.logout()
|
||||
target_path = "/superset/dashboard/nonexistent-dashboard/"
|
||||
|
||||
response = self.client.get(target_path, follow_redirects=False)
|
||||
|
||||
assert response.status_code == 302
|
||||
parsed = urlparse(response.headers["Location"])
|
||||
assert parsed.path.rstrip("/") == "/login"
|
||||
next_values = parse_qs(parsed.query).get("next")
|
||||
assert next_values is not None
|
||||
assert next_values[0].endswith(target_path)
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"public_role_like_gamma",
|
||||
"load_energy_table_with_slice",
|
||||
"load_dashboard",
|
||||
)
|
||||
def test_authenticated_user_without_access_gets_404(self):
|
||||
self.login(GAMMA_USERNAME)
|
||||
target_path = f"/superset/dashboard/{pytest.hidden_dash_slug}/"
|
||||
|
||||
response = self.client.get(
|
||||
target_path,
|
||||
follow_redirects=False,
|
||||
headers={"Accept": "text/html"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"public_role_like_gamma",
|
||||
"load_energy_table_with_slice",
|
||||
@@ -248,7 +314,3 @@ class TestDashboard(SupersetTestCase):
|
||||
db.session.commit()
|
||||
|
||||
assert f"/superset/dashboard/{slug}/" not in resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -108,7 +108,7 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
|
||||
|
||||
# act
|
||||
response = self.get_dashboard_view_response(dashboard_to_access)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 404 # Authenticated users without access get 404
|
||||
|
||||
request_payload = get_query_context("birth_names")
|
||||
rv = self.post_assert_metric(CHART_DATA_URI, request_payload, "data")
|
||||
@@ -221,7 +221,8 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
|
||||
response = self.get_dashboard_view_response(dashboard_to_access)
|
||||
|
||||
# assert
|
||||
assert response.status_code == 404
|
||||
# Anonymous users are redirected to login instead of getting 404
|
||||
assert response.status_code == 302
|
||||
|
||||
@pytest.mark.usefixtures("public_role_like_gamma")
|
||||
def test_get_dashboard_view__public_user_with_dashboard_permission_can_not_access_draft( # noqa: E501
|
||||
@@ -234,7 +235,8 @@ class TestDashboardRoleBasedSecurity(BaseTestDashboardSecurity):
|
||||
response = self.get_dashboard_view_response(dashboard_to_access)
|
||||
|
||||
# assert
|
||||
assert response.status_code == 404
|
||||
# Anonymous users are redirected to login for unpublished dashboards
|
||||
assert response.status_code == 302
|
||||
|
||||
# post
|
||||
revoke_access_to_dashboard(dashboard_to_access, "Public") # noqa: F405
|
||||
|
||||
@@ -190,6 +190,8 @@ def delete_all_inserted_objects() -> None:
|
||||
|
||||
def delete_all_inserted_dashboards():
|
||||
try:
|
||||
# Expire all objects to ensure fresh state after potential rollbacks
|
||||
db.session.expire_all()
|
||||
dashboards_to_delete: list[Dashboard] = (
|
||||
db.session.query(Dashboard)
|
||||
.filter(Dashboard.id.in_(inserted_dashboards_ids))
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
|
||||
from random import randint
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
@@ -25,7 +25,7 @@ from freezegun.api import FakeDatetime
|
||||
|
||||
from superset.extensions import db
|
||||
from superset.reports.models import ReportScheduleType
|
||||
from superset.tasks.scheduler import execute, scheduler
|
||||
from superset.tasks.scheduler import execute, log_task_failure, scheduler
|
||||
from tests.integration_tests.reports.utils import insert_report_schedule
|
||||
from tests.integration_tests.test_app import app
|
||||
|
||||
@@ -201,3 +201,48 @@ def test_execute_task_with_command_exception(
|
||||
|
||||
db.session.delete(report_schedule)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@patch("superset.tasks.scheduler.logger")
|
||||
def test_log_task_failure_with_sender(logger_mock):
|
||||
"""
|
||||
Test that log_task_failure logs correctly when sender is provided
|
||||
"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.name = "test.task.name"
|
||||
mock_exception = Exception("Test error")
|
||||
mock_einfo = MagicMock()
|
||||
|
||||
log_task_failure(
|
||||
sender=mock_task,
|
||||
task_id="test-task-id",
|
||||
exception=mock_exception,
|
||||
einfo=mock_einfo,
|
||||
)
|
||||
|
||||
logger_mock.exception.assert_called_once_with(
|
||||
"Celery task %s failed: %s",
|
||||
"test.task.name",
|
||||
mock_exception,
|
||||
exc_info=mock_einfo,
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.tasks.scheduler.logger")
|
||||
def test_log_task_failure_without_sender(logger_mock):
|
||||
"""
|
||||
Test that log_task_failure logs correctly when sender is None
|
||||
"""
|
||||
mock_exception = Exception("Test error")
|
||||
mock_einfo = MagicMock()
|
||||
|
||||
log_task_failure(
|
||||
sender=None,
|
||||
task_id="test-task-id",
|
||||
exception=mock_exception,
|
||||
einfo=mock_einfo,
|
||||
)
|
||||
|
||||
logger_mock.exception.assert_called_once_with(
|
||||
"Celery task %s failed: %s", "Unknown", mock_exception, exc_info=mock_einfo
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Unit tests for CreateSemanticLayerCommand."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.create import CreateSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerExistsValidationError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerRequiredFieldValidationError,
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test successful semantic layer creation.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
|
||||
dao.validate_uniqueness.return_value = True
|
||||
dao.create.return_value = mock_layer
|
||||
|
||||
properties = {
|
||||
"name": "test_layer",
|
||||
"type": "cube",
|
||||
"configuration": '{"url": "http://localhost:4000"}',
|
||||
}
|
||||
|
||||
command = CreateSemanticLayerCommand(properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_layer
|
||||
dao.create.assert_called_once_with(attributes=properties)
|
||||
|
||||
|
||||
def test_create_semantic_layer_missing_name(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create fails when name is missing.
|
||||
"""
|
||||
mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
|
||||
|
||||
properties = {
|
||||
"type": "cube",
|
||||
"configuration": '{"url": "http://localhost:4000"}',
|
||||
}
|
||||
|
||||
command = CreateSemanticLayerCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(
|
||||
exc_info.value._exceptions[0], SemanticLayerRequiredFieldValidationError
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_layer_missing_type(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create fails when type is missing.
|
||||
"""
|
||||
mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
|
||||
|
||||
properties = {
|
||||
"name": "test_layer",
|
||||
"configuration": '{"url": "http://localhost:4000"}',
|
||||
}
|
||||
|
||||
command = CreateSemanticLayerCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(
|
||||
exc_info.value._exceptions[0], SemanticLayerRequiredFieldValidationError
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create fails when name already exists.
|
||||
"""
|
||||
dao = mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
|
||||
dao.validate_uniqueness.return_value = False
|
||||
|
||||
properties = {
|
||||
"name": "existing_layer",
|
||||
"type": "cube",
|
||||
"configuration": '{"url": "http://localhost:4000"}',
|
||||
}
|
||||
|
||||
command = CreateSemanticLayerCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(exc_info.value._exceptions[0], SemanticLayerExistsValidationError)
|
||||
|
||||
|
||||
def test_create_semantic_layer_multiple_errors(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create accumulates multiple validation errors.
|
||||
"""
|
||||
mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
|
||||
|
||||
properties = {
|
||||
"configuration": '{"url": "http://localhost:4000"}',
|
||||
}
|
||||
|
||||
command = CreateSemanticLayerCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 2
|
||||
|
||||
|
||||
def test_create_semantic_layer_with_optional_fields(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create with optional fields.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.create.SemanticLayerDAO")
|
||||
dao.validate_uniqueness.return_value = True
|
||||
dao.create.return_value = mock_layer
|
||||
|
||||
properties = {
|
||||
"name": "test_layer",
|
||||
"type": "cube",
|
||||
"description": "Test description",
|
||||
"configuration": '{"url": "http://localhost:4000"}',
|
||||
"cache_timeout": 3600,
|
||||
}
|
||||
|
||||
command = CreateSemanticLayerCommand(properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_layer
|
||||
dao.create.assert_called_once_with(attributes=properties)
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for DeleteSemanticLayerCommand."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
|
||||
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
|
||||
|
||||
|
||||
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test successful semantic layer deletion.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.delete.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = mock_layer
|
||||
dao.delete.return_value = None
|
||||
|
||||
command = DeleteSemanticLayerCommand("test-uuid")
|
||||
result = command.run()
|
||||
|
||||
assert result is None
|
||||
dao.delete.assert_called_once_with([mock_layer])
|
||||
|
||||
|
||||
def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test delete fails when semantic layer not found.
|
||||
"""
|
||||
dao = mocker.patch("superset.commands.semantic_layer.delete.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = None
|
||||
|
||||
command = DeleteSemanticLayerCommand("nonexistent-uuid")
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
command.run()
|
||||
|
||||
|
||||
def test_delete_semantic_layer_cascades_views(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test delete cascades to semantic views.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
# Mock semantic views that will be cascade deleted
|
||||
mock_view1 = MagicMock()
|
||||
mock_view2 = MagicMock()
|
||||
mock_layer.semantic_views = [mock_view1, mock_view2]
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.delete.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = mock_layer
|
||||
dao.delete.return_value = None
|
||||
|
||||
command = DeleteSemanticLayerCommand("test-uuid")
|
||||
result = command.run()
|
||||
|
||||
assert result is None
|
||||
dao.delete.assert_called_once_with([mock_layer])
|
||||
@@ -0,0 +1,166 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Unit tests for UpdateSemanticLayerCommand."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import (
|
||||
SemanticLayerExistsValidationError,
|
||||
SemanticLayerInvalidError,
|
||||
SemanticLayerNotFoundError,
|
||||
)
|
||||
from superset.commands.semantic_layer.update import UpdateSemanticLayerCommand
|
||||
|
||||
|
||||
def test_update_semantic_layer_success(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test successful semantic layer update.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = mock_layer
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
dao.update.return_value = mock_layer
|
||||
|
||||
properties = {
|
||||
"description": "Updated description",
|
||||
"cache_timeout": 7200,
|
||||
}
|
||||
|
||||
command = UpdateSemanticLayerCommand("test-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_layer
|
||||
dao.update.assert_called_once_with(mock_layer, properties)
|
||||
|
||||
|
||||
def test_update_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update fails when semantic layer not found.
|
||||
"""
|
||||
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = None
|
||||
|
||||
properties = {"description": "Updated description"}
|
||||
|
||||
command = UpdateSemanticLayerCommand("nonexistent-uuid", properties)
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
command.run()
|
||||
|
||||
|
||||
def test_update_semantic_layer_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update fails when new name already exists.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = mock_layer
|
||||
dao.validate_update_uniqueness.return_value = False
|
||||
|
||||
properties = {"name": "existing_layer"}
|
||||
|
||||
command = UpdateSemanticLayerCommand("test-uuid", properties)
|
||||
|
||||
with pytest.raises(SemanticLayerInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(exc_info.value._exceptions[0], SemanticLayerExistsValidationError)
|
||||
|
||||
|
||||
def test_update_semantic_layer_name_unchanged(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update with same name doesn't trigger uniqueness validation.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = mock_layer
|
||||
dao.update.return_value = mock_layer
|
||||
|
||||
properties = {"description": "Updated description"}
|
||||
|
||||
command = UpdateSemanticLayerCommand("test-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_layer
|
||||
dao.validate_update_uniqueness.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_layer_name_changed(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update with new name triggers uniqueness validation.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = mock_layer
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
dao.update.return_value = mock_layer
|
||||
|
||||
properties = {"name": "new_layer_name"}
|
||||
|
||||
command = UpdateSemanticLayerCommand("test-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_layer
|
||||
dao.validate_update_uniqueness.assert_called_once_with(
|
||||
"test-uuid", "new_layer_name"
|
||||
)
|
||||
|
||||
|
||||
def test_update_semantic_layer_all_fields(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update with all fields.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "test-uuid"
|
||||
mock_layer.name = "test_layer"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_layer.update.SemanticLayerDAO")
|
||||
dao.find_by_id.return_value = mock_layer
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
dao.update.return_value = mock_layer
|
||||
|
||||
properties = {
|
||||
"name": "updated_layer",
|
||||
"description": "Updated description",
|
||||
"type": "dbt",
|
||||
"configuration": '{"token": "new-token"}',
|
||||
"cache_timeout": 7200,
|
||||
}
|
||||
|
||||
command = UpdateSemanticLayerCommand("test-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_layer
|
||||
dao.update.assert_called_once_with(mock_layer, properties)
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,210 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for CreateSemanticViewCommand."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_layer.exceptions import SemanticLayerNotFoundError
|
||||
from superset.commands.semantic_view.create import CreateSemanticViewCommand
|
||||
from superset.commands.semantic_view.exceptions import (
|
||||
SemanticViewExistsValidationError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewRequiredFieldValidationError,
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test successful semantic view creation.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "layer-uuid"
|
||||
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
|
||||
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
|
||||
layer_dao.find_by_id.return_value = mock_layer
|
||||
|
||||
view_dao = mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
|
||||
view_dao.validate_uniqueness.return_value = True
|
||||
view_dao.create.return_value = mock_view
|
||||
|
||||
properties = {
|
||||
"name": "test_view",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": '{"columns": ["id", "name"]}',
|
||||
}
|
||||
|
||||
command = CreateSemanticViewCommand(properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_view
|
||||
view_dao.create.assert_called_once_with(attributes=properties)
|
||||
|
||||
|
||||
def test_create_semantic_view_missing_name(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create fails when name is missing.
|
||||
"""
|
||||
mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
|
||||
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
|
||||
|
||||
properties = {
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": '{"columns": ["id"]}',
|
||||
}
|
||||
|
||||
command = CreateSemanticViewCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticViewInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(
|
||||
exc_info.value._exceptions[0], SemanticViewRequiredFieldValidationError
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_view_missing_semantic_layer_uuid(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""
|
||||
Test create fails when semantic_layer_uuid is missing.
|
||||
"""
|
||||
mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
|
||||
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
|
||||
|
||||
properties = {
|
||||
"name": "test_view",
|
||||
"configuration": '{"columns": ["id"]}',
|
||||
}
|
||||
|
||||
command = CreateSemanticViewCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticViewInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(
|
||||
exc_info.value._exceptions[0], SemanticViewRequiredFieldValidationError
|
||||
)
|
||||
|
||||
|
||||
def test_create_semantic_view_semantic_layer_not_found(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create fails when semantic layer not found.
|
||||
"""
|
||||
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
|
||||
layer_dao.find_by_id.return_value = None
|
||||
|
||||
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
|
||||
|
||||
properties = {
|
||||
"name": "test_view",
|
||||
"semantic_layer_uuid": "nonexistent-uuid",
|
||||
"configuration": '{"columns": ["id"]}',
|
||||
}
|
||||
|
||||
command = CreateSemanticViewCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticLayerNotFoundError):
|
||||
command.run()
|
||||
|
||||
|
||||
def test_create_semantic_view_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create fails when name already exists in layer.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "layer-uuid"
|
||||
|
||||
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
|
||||
layer_dao.find_by_id.return_value = mock_layer
|
||||
|
||||
view_dao = mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
|
||||
view_dao.validate_uniqueness.return_value = False
|
||||
|
||||
properties = {
|
||||
"name": "existing_view",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": '{"columns": ["id"]}',
|
||||
}
|
||||
|
||||
command = CreateSemanticViewCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticViewInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(exc_info.value._exceptions[0], SemanticViewExistsValidationError)
|
||||
|
||||
|
||||
def test_create_semantic_view_multiple_errors(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create accumulates multiple validation errors.
|
||||
"""
|
||||
mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
|
||||
mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
|
||||
|
||||
properties = {
|
||||
"configuration": '{"columns": ["id"]}',
|
||||
}
|
||||
|
||||
command = CreateSemanticViewCommand(properties)
|
||||
|
||||
with pytest.raises(SemanticViewInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 2
|
||||
|
||||
|
||||
def test_create_semantic_view_with_optional_fields(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test create with optional fields.
|
||||
"""
|
||||
mock_layer = MagicMock()
|
||||
mock_layer.uuid = "layer-uuid"
|
||||
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
|
||||
layer_dao = mocker.patch("superset.commands.semantic_view.create.SemanticLayerDAO")
|
||||
layer_dao.find_by_id.return_value = mock_layer
|
||||
|
||||
view_dao = mocker.patch("superset.commands.semantic_view.create.SemanticViewDAO")
|
||||
view_dao.validate_uniqueness.return_value = True
|
||||
view_dao.create.return_value = mock_view
|
||||
|
||||
properties = {
|
||||
"name": "test_view",
|
||||
"semantic_layer_uuid": "layer-uuid",
|
||||
"configuration": '{"columns": ["id", "name"]}',
|
||||
"cache_timeout": 1800,
|
||||
}
|
||||
|
||||
command = CreateSemanticViewCommand(properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_view
|
||||
view_dao.create.assert_called_once_with(attributes=properties)
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for DeleteSemanticViewCommand."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_view.delete import DeleteSemanticViewCommand
|
||||
from superset.commands.semantic_view.exceptions import SemanticViewNotFoundError
|
||||
|
||||
|
||||
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test successful semantic view deletion.
|
||||
"""
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_view.delete.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = mock_view
|
||||
dao.delete.return_value = None
|
||||
|
||||
command = DeleteSemanticViewCommand("view-uuid")
|
||||
result = command.run()
|
||||
|
||||
assert result is None
|
||||
dao.delete.assert_called_once_with([mock_view])
|
||||
|
||||
|
||||
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test delete fails when semantic view not found.
|
||||
"""
|
||||
dao = mocker.patch("superset.commands.semantic_view.delete.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = None
|
||||
|
||||
command = DeleteSemanticViewCommand("nonexistent-uuid")
|
||||
|
||||
with pytest.raises(SemanticViewNotFoundError):
|
||||
command.run()
|
||||
@@ -0,0 +1,169 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Unit tests for UpdateSemanticViewCommand."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.semantic_view.exceptions import (
|
||||
SemanticViewExistsValidationError,
|
||||
SemanticViewInvalidError,
|
||||
SemanticViewNotFoundError,
|
||||
)
|
||||
from superset.commands.semantic_view.update import UpdateSemanticViewCommand
|
||||
|
||||
|
||||
def test_update_semantic_view_success(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test successful semantic view update.
|
||||
"""
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
mock_view.semantic_layer_uuid = "layer-uuid"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = mock_view
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
dao.update.return_value = mock_view
|
||||
|
||||
properties = {
|
||||
"configuration": '{"columns": ["id", "name", "email"]}',
|
||||
"cache_timeout": 3600,
|
||||
}
|
||||
|
||||
command = UpdateSemanticViewCommand("view-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_view
|
||||
dao.update.assert_called_once_with(mock_view, properties)
|
||||
|
||||
|
||||
def test_update_semantic_view_not_found(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update fails when semantic view not found.
|
||||
"""
|
||||
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = None
|
||||
|
||||
properties = {"configuration": '{"columns": ["id"]}'}
|
||||
|
||||
command = UpdateSemanticViewCommand("nonexistent-uuid", properties)
|
||||
|
||||
with pytest.raises(SemanticViewNotFoundError):
|
||||
command.run()
|
||||
|
||||
|
||||
def test_update_semantic_view_duplicate_name(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update fails when new name already exists in layer.
|
||||
"""
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
mock_view.semantic_layer_uuid = "layer-uuid"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = mock_view
|
||||
dao.validate_update_uniqueness.return_value = False
|
||||
|
||||
properties = {"name": "existing_view"}
|
||||
|
||||
command = UpdateSemanticViewCommand("view-uuid", properties)
|
||||
|
||||
with pytest.raises(SemanticViewInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert len(exc_info.value._exceptions) == 1
|
||||
assert isinstance(exc_info.value._exceptions[0], SemanticViewExistsValidationError)
|
||||
|
||||
|
||||
def test_update_semantic_view_name_unchanged(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update with same name doesn't trigger uniqueness validation.
|
||||
"""
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
mock_view.semantic_layer_uuid = "layer-uuid"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = mock_view
|
||||
dao.update.return_value = mock_view
|
||||
|
||||
properties = {"configuration": '{"columns": ["id", "name"]}'}
|
||||
|
||||
command = UpdateSemanticViewCommand("view-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_view
|
||||
dao.validate_update_uniqueness.assert_not_called()
|
||||
|
||||
|
||||
def test_update_semantic_view_name_changed(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update with new name triggers uniqueness validation.
|
||||
"""
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
mock_view.semantic_layer_uuid = "layer-uuid"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = mock_view
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
dao.update.return_value = mock_view
|
||||
|
||||
properties = {"name": "new_view_name"}
|
||||
|
||||
command = UpdateSemanticViewCommand("view-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_view
|
||||
dao.validate_update_uniqueness.assert_called_once_with(
|
||||
"view-uuid", "new_view_name", "layer-uuid"
|
||||
)
|
||||
|
||||
|
||||
def test_update_semantic_view_all_fields(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
Test update with all fields.
|
||||
"""
|
||||
mock_view = MagicMock()
|
||||
mock_view.uuid = "view-uuid"
|
||||
mock_view.name = "test_view"
|
||||
mock_view.semantic_layer_uuid = "layer-uuid"
|
||||
|
||||
dao = mocker.patch("superset.commands.semantic_view.update.SemanticViewDAO")
|
||||
dao.find_by_id.return_value = mock_view
|
||||
dao.validate_update_uniqueness.return_value = True
|
||||
dao.update.return_value = mock_view
|
||||
|
||||
properties = {
|
||||
"name": "updated_view",
|
||||
"configuration": '{"columns": ["id", "name", "email"]}',
|
||||
"cache_timeout": 3600,
|
||||
}
|
||||
|
||||
command = UpdateSemanticViewCommand("view-uuid", properties)
|
||||
result = command.run()
|
||||
|
||||
assert result == mock_view
|
||||
dao.update.assert_called_once_with(mock_view, properties)
|
||||
@@ -0,0 +1,305 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for semantic layer DAOs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
|
||||
from superset.semantic_layers.models import SemanticLayer, SemanticView
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_with_data(session: Session) -> Iterator[Session]:
|
||||
"""
|
||||
Create session with semantic layer test data.
|
||||
"""
|
||||
|
||||
engine = session.get_bind()
|
||||
SemanticLayer.metadata.create_all(engine)
|
||||
|
||||
layer1 = SemanticLayer(
|
||||
uuid=uuid4(),
|
||||
name="layer1",
|
||||
description="First layer",
|
||||
type="cube",
|
||||
configuration='{"url": "http://localhost:4000"}',
|
||||
cache_timeout=3600,
|
||||
)
|
||||
layer2 = SemanticLayer(
|
||||
uuid=uuid4(),
|
||||
name="layer2",
|
||||
description="Second layer",
|
||||
type="dbt",
|
||||
configuration='{"token": "secret"}',
|
||||
)
|
||||
|
||||
session.add_all([layer1, layer2])
|
||||
session.flush()
|
||||
|
||||
view1 = SemanticView(
|
||||
uuid=uuid4(),
|
||||
name="view1",
|
||||
configuration='{"columns": ["id", "name"]}',
|
||||
cache_timeout=1800,
|
||||
semantic_layer_uuid=layer1.uuid,
|
||||
)
|
||||
view2 = SemanticView(
|
||||
uuid=uuid4(),
|
||||
name="view2",
|
||||
configuration='{"columns": ["id", "value"]}',
|
||||
semantic_layer_uuid=layer1.uuid,
|
||||
)
|
||||
view3 = SemanticView(
|
||||
uuid=uuid4(),
|
||||
name="view1",
|
||||
configuration='{"columns": ["id"]}',
|
||||
semantic_layer_uuid=layer2.uuid,
|
||||
)
|
||||
|
||||
session.add_all([view1, view2, view3])
|
||||
session.flush()
|
||||
|
||||
yield session
|
||||
session.rollback()
|
||||
|
||||
|
||||
def test_semantic_layer_find_by_name(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test finding semantic layer by name.
|
||||
"""
|
||||
result = SemanticLayerDAO.find_by_name("layer1")
|
||||
assert result is not None
|
||||
assert result.name == "layer1"
|
||||
assert result.description == "First layer"
|
||||
|
||||
|
||||
def test_semantic_layer_find_by_name_not_found(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test finding non-existent semantic layer by name.
|
||||
"""
|
||||
result = SemanticLayerDAO.find_by_name("nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_semantic_layer_validate_uniqueness_true(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test validating uniqueness returns True for new name.
|
||||
"""
|
||||
result = SemanticLayerDAO.validate_uniqueness("new_layer")
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_semantic_layer_validate_uniqueness_false(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test validating uniqueness returns False for existing name.
|
||||
"""
|
||||
result = SemanticLayerDAO.validate_uniqueness("layer1")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_semantic_layer_validate_update_uniqueness_same_name(
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test validating update uniqueness allows keeping same name.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
result = SemanticLayerDAO.validate_update_uniqueness(str(layer.uuid), "layer1")
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_semantic_layer_validate_update_uniqueness_new_name(
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test validating update uniqueness allows new unique name.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
result = SemanticLayerDAO.validate_update_uniqueness(str(layer.uuid), "new_name")
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_semantic_layer_validate_update_uniqueness_existing_name(
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test validating update uniqueness rejects existing name.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
result = SemanticLayerDAO.validate_update_uniqueness(str(layer.uuid), "layer2")
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_semantic_layer_get_semantic_views(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test getting all semantic views for a layer.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
views = SemanticLayerDAO.get_semantic_views(layer.uuid)
|
||||
assert len(views) == 2
|
||||
assert views[0].name in ["view1", "view2"]
|
||||
assert views[1].name in ["view1", "view2"]
|
||||
|
||||
|
||||
def test_semantic_view_find_by_semantic_layer(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test finding all views for a semantic layer.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
views = SemanticViewDAO.find_by_semantic_layer(layer.uuid)
|
||||
assert len(views) == 2
|
||||
assert all(view.semantic_layer_uuid == layer.uuid for view in views)
|
||||
|
||||
|
||||
def test_semantic_view_find_by_name(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test finding semantic view by name within layer.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
view = SemanticViewDAO.find_by_name("view1", layer.uuid)
|
||||
assert view is not None
|
||||
assert view.name == "view1"
|
||||
assert view.semantic_layer_uuid == layer.uuid
|
||||
|
||||
|
||||
def test_semantic_view_find_by_name_not_found(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test finding non-existent semantic view by name.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
view = SemanticViewDAO.find_by_name("nonexistent", layer.uuid)
|
||||
assert view is None
|
||||
|
||||
|
||||
def test_semantic_view_validate_uniqueness_true(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test validating uniqueness returns True for new name in layer.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
result = SemanticViewDAO.validate_uniqueness("new_view", layer.uuid)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_semantic_view_validate_uniqueness_false(session_with_data: Session) -> None:
|
||||
"""
|
||||
Test validating uniqueness returns False for existing name in layer.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
result = SemanticViewDAO.validate_uniqueness("view1", layer.uuid)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_semantic_view_validate_uniqueness_different_layer(
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test validating uniqueness allows same name in different layer.
|
||||
"""
|
||||
layer2 = session_with_data.query(SemanticLayer).filter_by(name="layer2").first()
|
||||
assert layer2 is not None
|
||||
|
||||
# view1 exists in layer1, but we're checking layer2 where view1 also exists
|
||||
# So this should return False
|
||||
result = SemanticViewDAO.validate_uniqueness("view1", layer2.uuid)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_semantic_view_validate_update_uniqueness_same_name(
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test validating update uniqueness allows keeping same name.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
view = (
|
||||
session_with_data.query(SemanticView)
|
||||
.filter_by(name="view1", semantic_layer_uuid=layer.uuid)
|
||||
.first()
|
||||
)
|
||||
assert view is not None
|
||||
|
||||
result = SemanticViewDAO.validate_update_uniqueness(view.uuid, "view1", layer.uuid)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_semantic_view_validate_update_uniqueness_new_name(
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test validating update uniqueness allows new unique name.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
view = (
|
||||
session_with_data.query(SemanticView)
|
||||
.filter_by(name="view1", semantic_layer_uuid=layer.uuid)
|
||||
.first()
|
||||
)
|
||||
assert view is not None
|
||||
|
||||
result = SemanticViewDAO.validate_update_uniqueness(
|
||||
view.uuid, "new_view", layer.uuid
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_semantic_view_validate_update_uniqueness_existing_name(
|
||||
session_with_data: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test validating update uniqueness rejects existing name in same layer.
|
||||
"""
|
||||
layer = session_with_data.query(SemanticLayer).filter_by(name="layer1").first()
|
||||
assert layer is not None
|
||||
|
||||
view = (
|
||||
session_with_data.query(SemanticView)
|
||||
.filter_by(name="view1", semantic_layer_uuid=layer.uuid)
|
||||
.first()
|
||||
)
|
||||
assert view is not None
|
||||
|
||||
result = SemanticViewDAO.validate_update_uniqueness(view.uuid, "view2", layer.uuid)
|
||||
assert result is False
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,356 @@
|
||||
# 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.
|
||||
|
||||
# flake8: noqa: E501
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.semantic_layers.snowflake import (
|
||||
SnowflakeConfiguration,
|
||||
SnowflakeSemanticLayer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration, databases, schemas, expected_db_enum, expected_schema_enum",
|
||||
[
|
||||
# No configuration - empty enums
|
||||
(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
[],
|
||||
[],
|
||||
),
|
||||
# Configuration with account + auth - populates databases
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_database": True,
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
["ANALYTICS_DB", "SALES_DB", "MARKETING_DB"],
|
||||
None,
|
||||
["ANALYTICS_DB", "SALES_DB", "MARKETING_DB"],
|
||||
[],
|
||||
),
|
||||
# Configuration with account + auth + database - populates both
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"database": "ANALYTICS_DB",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
["ANALYTICS_DB", "SALES_DB", "MARKETING_DB"],
|
||||
["PUBLIC", "STAGING", "DEV"],
|
||||
["ANALYTICS_DB", "SALES_DB", "MARKETING_DB"],
|
||||
["PUBLIC", "STAGING", "DEV"],
|
||||
),
|
||||
# Configuration with account + auth, single database
|
||||
(
|
||||
{
|
||||
"account_identifier": "prod_account",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
},
|
||||
"allow_changing_database": True,
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
["PRODUCTION"],
|
||||
None,
|
||||
["PRODUCTION"],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_configuration_schema(
|
||||
configuration: dict[str, Any] | None,
|
||||
databases: list[str] | None,
|
||||
schemas: list[str] | None,
|
||||
expected_db_enum: list[str],
|
||||
expected_schema_enum: list[str],
|
||||
) -> None:
|
||||
"""
|
||||
Test configuration schema generation with dynamic database/schema enums.
|
||||
"""
|
||||
if configuration is None:
|
||||
# Test without configuration
|
||||
schema = SnowflakeSemanticLayer.get_configuration_schema()
|
||||
|
||||
assert "properties" in schema
|
||||
assert "database" in schema["properties"]
|
||||
assert "schema" in schema["properties"]
|
||||
assert schema["properties"]["database"]["enum"] == expected_db_enum
|
||||
assert schema["properties"]["schema"]["enum"] == expected_schema_enum
|
||||
else:
|
||||
# Create configuration
|
||||
config = SnowflakeConfiguration(**configuration)
|
||||
|
||||
# Mock the connection and cursor
|
||||
mock_cursor = MagicMock()
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
|
||||
# Setup cursor responses
|
||||
if databases:
|
||||
# SHOW DATABASES returns (name, name, ...)
|
||||
mock_cursor.__iter__.return_value = iter(
|
||||
[(i, db, "", "", "", "", "") for i, db in enumerate(databases)]
|
||||
)
|
||||
|
||||
if schemas:
|
||||
# SELECT SCHEMA_NAME returns (schema_name,)
|
||||
mock_cursor.execute.return_value = iter([(schema,) for schema in schemas])
|
||||
|
||||
# Mock connect to return our mock connection
|
||||
with patch(
|
||||
"superset.semantic_layers.snowflake.semantic_layer.connect"
|
||||
) as mock_connect:
|
||||
mock_connect.return_value.__enter__.return_value = mock_connection
|
||||
|
||||
# Get the schema
|
||||
schema = SnowflakeSemanticLayer.get_configuration_schema(config)
|
||||
|
||||
# Verify connect was called
|
||||
mock_connect.assert_called_once()
|
||||
|
||||
# Verify schema structure
|
||||
assert "properties" in schema
|
||||
assert "database" in schema["properties"]
|
||||
assert "schema" in schema["properties"]
|
||||
|
||||
# Verify database enum (compare as sets since order isn't guaranteed)
|
||||
assert set(schema["properties"]["database"]["enum"]) == set(
|
||||
expected_db_enum
|
||||
)
|
||||
|
||||
# Verify schema enum (may not have 'enum' key if database not set)
|
||||
if expected_schema_enum:
|
||||
assert set(schema["properties"]["schema"]["enum"]) == set(
|
||||
expected_schema_enum
|
||||
)
|
||||
else:
|
||||
# When no schemas are expected, enum key may not exist
|
||||
# or may be an empty list
|
||||
schema_enum = schema["properties"]["schema"].get("enum", [])
|
||||
assert set(schema_enum) == set(expected_schema_enum)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration, runtime_data, databases, schemas, expect_database, expect_schema",
|
||||
[
|
||||
# Database + schema configured, no changing allowed -> empty runtime schema
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"database": "ANALYTICS_DB",
|
||||
"schema": "PUBLIC",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_database": False,
|
||||
"allow_changing_schema": False,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
# Database configured, schema not configured -> shows schemas
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"database": "ANALYTICS_DB",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
["PUBLIC", "STAGING", "DEV"],
|
||||
False,
|
||||
True,
|
||||
),
|
||||
# Database configured, allow_changing_schema=True -> shows schemas
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"database": "ANALYTICS_DB",
|
||||
"schema": "PUBLIC",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
["PUBLIC", "STAGING", "DEV"],
|
||||
False,
|
||||
True,
|
||||
),
|
||||
# Database not configured -> shows databases
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_database": True,
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
None,
|
||||
["ANALYTICS_DB", "SALES_DB"],
|
||||
None,
|
||||
True,
|
||||
True,
|
||||
),
|
||||
# Database configured, allow_changing_database=True -> shows databases
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"database": "ANALYTICS_DB",
|
||||
"schema": "PUBLIC",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_database": True,
|
||||
"allow_changing_schema": False,
|
||||
},
|
||||
None,
|
||||
["ANALYTICS_DB", "SALES_DB"],
|
||||
None,
|
||||
True,
|
||||
False,
|
||||
),
|
||||
# Runtime data provides database -> shows schemas for that database
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_database": True,
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
{"database": "SALES_DB"},
|
||||
["ANALYTICS_DB", "SALES_DB"],
|
||||
["SALES_SCHEMA", "CUSTOMER_SCHEMA"],
|
||||
True,
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_runtime_schema(
|
||||
configuration: dict[str, Any],
|
||||
runtime_data: dict[str, Any] | None,
|
||||
databases: list[str] | None,
|
||||
schemas: list[str] | None,
|
||||
expect_database: bool,
|
||||
expect_schema: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Test runtime schema generation with various configuration combinations.
|
||||
|
||||
The runtime schema should only include fields that the user can change:
|
||||
- database field if database is not configured or changing is allowed
|
||||
- schema field if schema is not configured or changing is allowed
|
||||
"""
|
||||
# Create configuration
|
||||
config = SnowflakeConfiguration(**configuration)
|
||||
|
||||
# Mock the connection and cursor
|
||||
mock_cursor = MagicMock()
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.cursor.return_value = mock_cursor
|
||||
|
||||
# Setup cursor responses
|
||||
if databases:
|
||||
# SHOW DATABASES returns (name, name, ...)
|
||||
mock_cursor.__iter__.return_value = iter(
|
||||
[(i, db, "", "", "", "", "") for i, db in enumerate(databases)]
|
||||
)
|
||||
|
||||
if schemas:
|
||||
# SELECT SCHEMA_NAME returns (schema_name,)
|
||||
mock_cursor.execute.return_value = iter([(schema,) for schema in schemas])
|
||||
|
||||
# Mock connect to return our mock connection
|
||||
with patch(
|
||||
"superset.semantic_layers.snowflake.semantic_layer.connect"
|
||||
) as mock_connect:
|
||||
mock_connect.return_value.__enter__.return_value = mock_connection
|
||||
|
||||
# Get the runtime schema
|
||||
schema = SnowflakeSemanticLayer.get_runtime_schema(config, runtime_data)
|
||||
|
||||
# Verify connect was called
|
||||
mock_connect.assert_called_once()
|
||||
|
||||
# Verify schema structure
|
||||
assert "properties" in schema
|
||||
|
||||
# Verify database field presence
|
||||
if expect_database:
|
||||
assert "database" in schema["properties"]
|
||||
# Should have enum with available databases
|
||||
if databases:
|
||||
db_enum = schema["properties"]["database"].get("enum", [])
|
||||
assert set(db_enum) == set(databases)
|
||||
else:
|
||||
assert "database" not in schema["properties"]
|
||||
|
||||
# Verify schema field presence
|
||||
if expect_schema:
|
||||
assert "schema" in schema["properties"]
|
||||
# Should have enum with available schemas if we have a database
|
||||
if schemas and (
|
||||
configuration.get("database")
|
||||
or (runtime_data and runtime_data.get("database"))
|
||||
):
|
||||
schema_enum = schema["properties"]["schema"].get("enum", [])
|
||||
assert set(schema_enum) == set(schemas)
|
||||
else:
|
||||
assert "schema" not in schema["properties"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
# 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.
|
||||
|
||||
# flake8: noqa: E501
|
||||
|
||||
from contextlib import nullcontext
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.semantic_layers.snowflake import SnowflakeConfiguration
|
||||
from superset.semantic_layers.snowflake.utils import (
|
||||
get_connection_parameters,
|
||||
substitute_parameters,
|
||||
validate_order_by,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query, parameters, expected",
|
||||
[
|
||||
# No parameters
|
||||
("SELECT * FROM table", None, "SELECT * FROM table"),
|
||||
("SELECT * FROM table", [], "SELECT * FROM table"),
|
||||
# NULL values
|
||||
(
|
||||
"SELECT * FROM table WHERE id = ?",
|
||||
[None],
|
||||
"SELECT * FROM table WHERE id = NULL",
|
||||
),
|
||||
# Integer values
|
||||
(
|
||||
"SELECT * FROM table WHERE id = ?",
|
||||
[123],
|
||||
"SELECT * FROM table WHERE id = 123",
|
||||
),
|
||||
(
|
||||
"SELECT * FROM table WHERE id = ? AND status = ?",
|
||||
[123, 456],
|
||||
"SELECT * FROM table WHERE id = 123 AND status = 456",
|
||||
),
|
||||
# Float values
|
||||
(
|
||||
"SELECT * FROM table WHERE price = ?",
|
||||
[99.99],
|
||||
"SELECT * FROM table WHERE price = 99.99",
|
||||
),
|
||||
(
|
||||
"SELECT * FROM table WHERE price BETWEEN ? AND ?",
|
||||
[10.5, 99.99],
|
||||
"SELECT * FROM table WHERE price BETWEEN 10.5 AND 99.99",
|
||||
),
|
||||
# Boolean values
|
||||
(
|
||||
"SELECT * FROM table WHERE active = ?",
|
||||
[True],
|
||||
"SELECT * FROM table WHERE active = TRUE",
|
||||
),
|
||||
(
|
||||
"SELECT * FROM table WHERE active = ? AND deleted = ?",
|
||||
[True, False],
|
||||
"SELECT * FROM table WHERE active = TRUE AND deleted = FALSE",
|
||||
),
|
||||
# String values
|
||||
(
|
||||
"SELECT * FROM table WHERE name = ?",
|
||||
["John"],
|
||||
"SELECT * FROM table WHERE name = 'John'",
|
||||
),
|
||||
(
|
||||
"SELECT * FROM table WHERE name = ? OR name = ?",
|
||||
["John", "Jane"],
|
||||
"SELECT * FROM table WHERE name = 'John' OR name = 'Jane'",
|
||||
),
|
||||
# String with single quotes (should be escaped)
|
||||
(
|
||||
"SELECT * FROM table WHERE name = ?",
|
||||
["O'Brien"],
|
||||
"SELECT * FROM table WHERE name = 'O''Brien'",
|
||||
),
|
||||
(
|
||||
"SELECT * FROM table WHERE text = ?",
|
||||
["It's a test"],
|
||||
"SELECT * FROM table WHERE text = 'It''s a test'",
|
||||
),
|
||||
# Mixed types
|
||||
(
|
||||
(
|
||||
"SELECT * FROM table WHERE name = ? "
|
||||
"AND age = ? AND active = ? AND salary = ?"
|
||||
),
|
||||
["John", 30, True, 50000.5],
|
||||
(
|
||||
"SELECT * FROM table WHERE name = 'John' "
|
||||
"AND age = 30 AND active = TRUE AND salary = 50000.5"
|
||||
),
|
||||
),
|
||||
(
|
||||
"SELECT * FROM table WHERE col1 = ? AND col2 = ? AND col3 = ?",
|
||||
[None, "test", 42],
|
||||
"SELECT * FROM table WHERE col1 = NULL AND col2 = 'test' AND col3 = 42",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_substitute_parameters(
|
||||
query: str,
|
||||
parameters: list[Any] | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""
|
||||
Test parameter substitution for various types and combinations.
|
||||
"""
|
||||
assert substitute_parameters(query, parameters) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"definition, should_raise",
|
||||
[
|
||||
# Valid simple cases
|
||||
("column_name", False),
|
||||
("COUNT(*)", False),
|
||||
("SUM(amount)", False),
|
||||
("table.column", False),
|
||||
("schema.table.column", False),
|
||||
# Valid with direction
|
||||
("column_name ASC", False),
|
||||
("column_name DESC", False),
|
||||
("COUNT(*) DESC", False),
|
||||
("SUM(revenue) ASC", False),
|
||||
# Valid with NULLS handling
|
||||
("column_name NULLS FIRST", False),
|
||||
("column_name NULLS LAST", False),
|
||||
("column_name ASC NULLS FIRST", False),
|
||||
("column_name DESC NULLS LAST", False),
|
||||
("COUNT(*) DESC NULLS FIRST", False),
|
||||
# Valid complex expressions
|
||||
("gender ASC, COUNT(*)", False),
|
||||
("gender ASC, COUNT(*) DESC", False),
|
||||
("col1 ASC, col2 DESC, col3", False),
|
||||
("CASE WHEN x > 0 THEN 1 ELSE 0 END", False),
|
||||
("CAST(column AS INTEGER)", False),
|
||||
("UPPER(name)", False),
|
||||
("CONCAT(first_name, ' ', last_name)", False),
|
||||
# Valid with mixed complexity
|
||||
("table.column ASC NULLS FIRST, COUNT(*) DESC", False),
|
||||
("schema.table.col1, func(col2) DESC NULLS LAST", False),
|
||||
# Invalid - SQL injection attempts with semicolons
|
||||
("column_name; DROP TABLE users;", True),
|
||||
("column_name; DELETE FROM data; --", True),
|
||||
("name; UPDATE users SET admin=1; --", True),
|
||||
# Invalid - SQL injection with multiple statements
|
||||
("col1; SELECT * FROM passwords", True),
|
||||
("col1; INSERT INTO logs VALUES(1)", True),
|
||||
# Edge cases - incomplete syntax
|
||||
("column/*", True),
|
||||
],
|
||||
)
|
||||
def test_validate_order_by(definition: str, should_raise: bool) -> None:
|
||||
"""
|
||||
Test ORDER BY validation for valid expressions and SQL injection prevention.
|
||||
"""
|
||||
context = (
|
||||
pytest.raises(ValueError, match="Invalid ORDER BY")
|
||||
if should_raise
|
||||
else nullcontext()
|
||||
)
|
||||
with context:
|
||||
validate_order_by(definition)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration, expected",
|
||||
[
|
||||
# Minimal UserPasswordAuth configuration
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
"allow_changing_database": True,
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
{
|
||||
"account": "test_account",
|
||||
"application": "Apache Superset",
|
||||
"paramstyle": "qmark",
|
||||
"insecure_mode": True,
|
||||
"user": "test_user",
|
||||
"password": "test_password",
|
||||
},
|
||||
),
|
||||
# Full UserPasswordAuth configuration
|
||||
(
|
||||
{
|
||||
"account_identifier": "test_account",
|
||||
"role": "ACCOUNTADMIN",
|
||||
"warehouse": "COMPUTE_WH",
|
||||
"database": "TEST_DB",
|
||||
"schema": "PUBLIC",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "admin",
|
||||
"password": "secret123",
|
||||
},
|
||||
},
|
||||
{
|
||||
"account": "test_account",
|
||||
"application": "Apache Superset",
|
||||
"paramstyle": "qmark",
|
||||
"insecure_mode": True,
|
||||
"role": "ACCOUNTADMIN",
|
||||
"warehouse": "COMPUTE_WH",
|
||||
"database": "TEST_DB",
|
||||
"schema": "PUBLIC",
|
||||
"user": "admin",
|
||||
"password": "secret123",
|
||||
},
|
||||
),
|
||||
# UserPasswordAuth with some optional fields
|
||||
(
|
||||
{
|
||||
"account_identifier": "mycompany.us-east-1",
|
||||
"warehouse": "ETL_WH",
|
||||
"database": "ANALYTICS",
|
||||
"auth": {
|
||||
"auth_type": "user_password",
|
||||
"username": "analyst",
|
||||
"password": "p@ssw0rd",
|
||||
},
|
||||
"allow_changing_schema": True,
|
||||
},
|
||||
{
|
||||
"account": "mycompany.us-east-1",
|
||||
"application": "Apache Superset",
|
||||
"paramstyle": "qmark",
|
||||
"insecure_mode": True,
|
||||
"warehouse": "ETL_WH",
|
||||
"database": "ANALYTICS",
|
||||
"user": "analyst",
|
||||
"password": "p@ssw0rd",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_connection_parameters(
|
||||
configuration: dict[str, Any],
|
||||
expected: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Test connection parameter generation for various configurations.
|
||||
"""
|
||||
# Create configuration from params
|
||||
config = SnowflakeConfiguration(**configuration)
|
||||
|
||||
# Get connection parameters
|
||||
result = get_connection_parameters(config)
|
||||
|
||||
# Check that all expected keys are present with correct values
|
||||
for key, value in expected.items():
|
||||
assert key in result, f"Expected key '{key}' not found in result"
|
||||
assert result[key] == value, f"Expected {key}={value}, got {result[key]}"
|
||||
|
||||
# Verify no unexpected keys
|
||||
assert set(result.keys()) == set(expected.keys())
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user