mirror of
https://github.com/apache/superset.git
synced 2026-08-28 02:51:18 +00:00
Compare commits
98
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 | ||
|
|
1617bbbe71 | ||
|
|
de1dd53186 | ||
|
|
58672dfab6 | ||
|
|
4b5629d1c8 | ||
|
|
4ddc3f14ed | ||
|
|
400a8aec89 | ||
|
|
51489a75ce | ||
|
|
09772eeda0 | ||
|
|
78907d08cd | ||
|
|
d0a0d280a1 | ||
|
|
5d77ed3677 | ||
|
|
f68ee6ba67 | ||
|
|
a01560cfa1 | ||
|
|
7e06ce8eeb | ||
|
|
ccc0e3dbb2 | ||
|
|
bd48e87eeb |
@@ -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:
|
||||
```
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Securing Your Superset Installation for Production
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
> *This guide applies to Apache Superset version 4.0 and later and is an evolving set of best practices that administrators should adapt to their specific deployment architecture.*
|
||||
|
||||
The default Apache Superset configuration is optimized for ease of use and development, not for security. For any production deployment, it is **critical** that you review and apply the following security configurations to harden your instance, protect user data, and prevent unauthorized access.
|
||||
|
||||
This guide provides a comprehensive checklist of essential security configurations and best practices.
|
||||
|
||||
### **Critical Prerequisites: HTTPS/TLS Configuration**
|
||||
|
||||
Running Superset without HTTPS (TLS) is not secure. Without it, all network traffic—including user credentials, session tokens, and sensitive data—is sent in cleartext and can be easily intercepted.
|
||||
|
||||
* **Use a Reverse Proxy:** Your Superset instance should always be deployed behind a reverse proxy (e.g., Nginx, Traefik) or a load balancer (e.g., AWS ALB, Google Cloud Load Balancer) that is configured to handle HTTPS termination.
|
||||
* **Enforce Modern TLS:** Configure your proxy to enforce TLS 1.2 or higher with strong, industry-standard cipher suites.
|
||||
* **Implement HSTS:** Use the HTTP Strict Transport Security (HSTS) header to ensure browsers only connect to your Superset instance over HTTPS. This can be configured in your reverse proxy or within Superset's Talisman settings.
|
||||
|
||||
### **`SUPERSET_SECRET_KEY` Management (CRITICAL)**
|
||||
|
||||
This is the most critical security setting for your Superset instance. It is used to sign all session cookies and encrypt sensitive information in the metadata database, such as database connection credentials.
|
||||
|
||||
* **Generate a Unique, Strong Key:** A unique key must be generated for every Superset instance. Use a cryptographically secure method to create it.
|
||||
```bash
|
||||
# Example using openssl to generate a strong key
|
||||
openssl rand -base64 42
|
||||
```
|
||||
* **Store the Key Securely:** The key must be kept confidential. The recommended approach is to store it as an environment variable or in a secrets management system (e.g., AWS Secrets Manager, HashiCorp Vault). **Do not hardcode the key in `superset_config.py` or commit it to version control.**
|
||||
```python
|
||||
# In superset_config.py
|
||||
import os
|
||||
SECRET_KEY = os.environ.get('SUPERSET_SECRET_KEY')
|
||||
```
|
||||
|
||||
> #### ⚠️ Warning: Your `SUPERSET_SECRET_KEY` Must Be Unique
|
||||
>
|
||||
> **NEVER** reuse the same `SUPERSET_SECRET_KEY` across different environments (e.g., development, staging, production) or different Superset instances. Reusing a key allows cryptographically signed session cookies to be used across those instances, which can lead to a full authentication bypass if a cookie is compromised. Treat this key like a master password.
|
||||
|
||||
### **Session Management Security (CRITICAL)**
|
||||
|
||||
Properly configuring user sessions is essential to prevent session hijacking and ensure that sessions are terminated correctly.
|
||||
|
||||
#### **Use a Server-Side Session Backend (Strongly Recommended for Production)**
|
||||
|
||||
The default stateless cookie-based session handling presents challenges for immediate session invalidation upon logout. For all production deployments, we strongly recommend configuring an optional server-side session backend like Redis, Memcached, or a database. This ensures that session data is stored securely on the server and can be instantly destroyed upon logout, rendering any copied session cookies immediately useless.
|
||||
|
||||
**Example `superset_config.py` for Redis:**
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
from redis import Redis
|
||||
import os
|
||||
|
||||
# 1. Enable server-side sessions
|
||||
SESSION_SERVER_SIDE = True
|
||||
|
||||
# 2. Choose your backend (e.g., 'redis', 'memcached', 'filesystem', 'sqlalchemy')
|
||||
SESSION_TYPE = 'redis'
|
||||
|
||||
# 3. Configure your Redis connection
|
||||
# Use environment variables for sensitive details
|
||||
SESSION_REDIS = Redis(
|
||||
host=os.environ.get('REDIS_HOST', 'localhost'),
|
||||
port=int(os.environ.get('REDIS_PORT', 6379)),
|
||||
password=os.environ.get('REDIS_PASSWORD'),
|
||||
db=int(os.environ.get('REDIS_DB', 0)),
|
||||
ssl=os.environ.get('REDIS_SSL_ENABLED', 'True').lower() == 'true',
|
||||
ssl_cert_reqs='required' # Or another appropriate SSL setting
|
||||
)
|
||||
|
||||
# 4. Ensure the session cookie is signed for integrity
|
||||
SESSION_USE_SIGNER = True
|
||||
```
|
||||
|
||||
#### **Configure Session Lifetime and Cookie Security Flags**
|
||||
|
||||
This is mandatory for *all* deployments, whether stateless or server-side.
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
from datetime import timedelta
|
||||
|
||||
# Set a short absolute session timeout
|
||||
# The default is 31 days, which is NOT recommended for production.
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=8)
|
||||
|
||||
# Enforce secure cookie flags to prevent browser-based attacks
|
||||
SESSION_COOKIE_SECURE = True # Transmit cookie only over HTTPS
|
||||
SESSION_COOKIE_HTTPONLY = True # Prevent client-side JS from accessing the cookie
|
||||
SESSION_COOKIE_SAMESITE = 'Lax' # Provide protection against CSRF attacks
|
||||
```
|
||||
|
||||
> ##### Note on iFrame Embedding and `SESSION_COOKIE_SAMESITE`
|
||||
>The recommended default setting `'Lax'` provides good CSRF protection for most use cases. However, if you need to embed Superset dashboards into other applications using an iFrame, you will need to change this setting to `'None'`.
|
||||
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
|
||||
Setting SameSite to 'None' requires that SESSION_COOKIE_SECURE is also set to True. Be aware that this configuration disables some of the browser's built-in CSRF protections to allow for cross-domain functionality, so it should only be used when iFrame embedding is necessary.
|
||||
|
||||
### **Authentication and Authorization**
|
||||
|
||||
While Superset's built-in database authentication is convenient, for production it's highly recommended to integrate with an enterprise-grade identity provider (IdP).
|
||||
|
||||
* **Use an Enterprise IdP:** Configure authentication via OAuth or LDAP to leverage your organization's existing identity management system. This provides benefits like Single Sign-On (SSO), Multi-Factor Authentication (MFA), and centralized user provisioning/deprovisioning.
|
||||
* **Principle of Least Privilege:** Assign users to the most restrictive roles necessary for their jobs. Avoid over-provisioning users with Admin or Alpha roles, and ensure row-level security is applied where appropriate.
|
||||
* **Admin Accounts:** Delete or disable the default admin user after a new administrative account has been configured.
|
||||
|
||||
### **Content Security Policy (CSP) and Other Headers**
|
||||
|
||||
Superset can use Flask-Talisman to set security headers. However, it must be explicitly enabled.
|
||||
|
||||
> #### ⚠️ Important: Talisman is Disabled by Default
|
||||
>
|
||||
> In Superset 4.0 and later, Talisman is disabled by default (`TALISMAN_ENABLED = False`). You **must** explicitly enable it in your `superset_config.py` for the security headers defined in `TALISMAN_CONFIG` to take effect.
|
||||
|
||||
Here's the documentation section how how to set up Talisman: https://superset.apache.org/docs/security/#content-security-policy-csp
|
||||
|
||||
### **Database Security**
|
||||
|
||||
> #### ❗ Superset is Not a Database Firewall
|
||||
>
|
||||
> It is essential to understand that **Apache Superset is a data visualization and exploration platform, not a database firewall or a comprehensive security solution for your data warehouse.** While Superset provides features to help manage data access, the ultimate responsibility for securing your underlying databases lies with your database administrators (DBAs) and security teams. This includes managing network access, user privileges, and fine-grained permissions directly within the database. The configurations below are an important secondary layer of security but should not be your only line of defense.
|
||||
|
||||
* **Use a Dedicated Database User:** The database connection configured in Superset should use a dedicated, limited-privilege database user. This user should only have the minimum required permissions (e.g., `SELECT` on specific schemas) for the data sources it needs to query. It should **not** have `INSERT`, `UPDATE`, `DELETE`, or administrative privileges.
|
||||
* **Restrict Dangerous SQL Functions:** To mitigate potential SQL injection risks, configure the `DISALLOWED_SQL_FUNCTIONS` list in your `superset_config.py`. Be aware that this is a defense-in-depth measure, not a substitute for proper database permissions.
|
||||
|
||||
### **Additional Security Layers**
|
||||
|
||||
* **Web Application Firewall (WAF):** Deploying Superset behind a WAF (e.g., Cloudflare, AWS WAF) is strongly recommended. A WAF with a standard ruleset (like the OWASP Core Rule Set) provides a critical layer of defense against common attacks like SQL Injection, XSS, and remote code execution.
|
||||
|
||||
### **Monitoring and Logging**
|
||||
|
||||
* **Configure Structured Logging:** Set up a robust logging configuration to capture important security events.
|
||||
* **Centralize Logs:** Ship logs from all Superset components (frontend, worker, etc.) to a centralized SIEM (Security Information and Event Management) system for analysis and alerting.
|
||||
* **Monitor Key Events:** Create alerts for suspicious activities, including:
|
||||
* Multiple failed login attempts for a single user or from a single IP address.
|
||||
* Changes to user roles or permissions.
|
||||
* Creation or deletion of high-privilege users.
|
||||
* Attempts to use disallowed SQL functions.
|
||||
|
||||
-----
|
||||
|
||||
### **Appendix A: Production Deployment Checklist**
|
||||
|
||||
#### **Initial Setup:**
|
||||
|
||||
- [ ] HTTPS/TLS is configured and enforced via a reverse proxy.
|
||||
- [ ] A unique, strong `SUPERSET_SECRET_KEY` is generated and secured in an environment variable or secrets vault.
|
||||
- [ ] Server-side session management is configured (e.g., Redis).
|
||||
- [ ] `PERMANENT_SESSION_LIFETIME` is set to a short duration (e.g., 8 hours).
|
||||
- [ ] All session cookie security flags (`Secure`, `HttpOnly`, `SameSite`) are enabled.
|
||||
- [ ] `DEBUG` mode is set to `False`.
|
||||
- [ ] Talisman is explicitly enabled and configured with a strict Content Security Policy.
|
||||
- [ ] Database connections use dedicated, limited-privilege accounts.
|
||||
- [ ] Authentication is integrated with an enterprise identity provider (OAuth/LDAP).
|
||||
- [ ] A Web Application Firewall (WAF) is deployed in front of Superset.
|
||||
- [ ] Logging is configured and logs are shipped to a central monitoring system.
|
||||
|
||||
#### **Ongoing Maintenance:**
|
||||
|
||||
- [ ] Regularly update to the latest major or minor versions of Superset. Those versions receive up-to-date security patches.
|
||||
- [ ] Rotate the `SUPERSET_SECRET_KEY` periodically (e.g., quarterly) and after any potential security incident.
|
||||
- [ ] Conduct quarterly access reviews for all users.
|
||||
- [ ] Assuming logging and monitoring is in place, review security monitoring alerts weekly.
|
||||
|
||||
### **Appendix B: `SECRET_KEY` Rotation and Compromise Response**
|
||||
|
||||
**Why and When to Rotate the `SECRET_KEY`**
|
||||
Rotating the `SUPERSET_SECRET_KEY` is a critical security procedure. It is mandatory after a known or suspected compromise and is a best practice when an employee with access to the key departs. While periodic rotation can limit the window of exposure for an unknown leak, it is a high-impact operation that will invalidate all user sessions and requires careful execution to avoid breaking your instance. The principles behind managing this key align with general best practices for cryptographic storage, which are further detailed in the OWASP Cryptographic Storage Cheat Sheet here: https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html
|
||||
|
||||
**Procedure for Rotating the Key**
|
||||
The procedure for safely rotating the SECRET_KEY must be followed precisely to avoid locking yourself out of your instance. The official Apache Superset documentation maintains the correct, up-to-date procedure. Please follow the official guide here:
|
||||
https://superset.apache.org/docs/configuration/configuring-superset/#rotating-to-a-newer-secret_key
|
||||
+10
-10
@@ -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.30001749",
|
||||
"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.3",
|
||||
"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",
|
||||
@@ -81,7 +81,7 @@
|
||||
"globals": "^16.4.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.0",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"webpack": "^5.102.1"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
+337
-333
@@ -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"
|
||||
@@ -4336,79 +4340,79 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.46.0", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz#fc90b35d8025b5eaa66b2f6c3859cd5381a1e751"
|
||||
integrity sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==
|
||||
"@typescript-eslint/eslint-plugin@8.46.1", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz#20876354024140aabc8b400bc95735fdcade17d5"
|
||||
integrity sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.10.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.0"
|
||||
"@typescript-eslint/type-utils" "8.46.0"
|
||||
"@typescript-eslint/utils" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.1"
|
||||
"@typescript-eslint/type-utils" "8.46.1"
|
||||
"@typescript-eslint/utils" "8.46.1"
|
||||
"@typescript-eslint/visitor-keys" "8.46.1"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^7.0.0"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/parser@8.46.0", "@typescript-eslint/parser@^8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.0.tgz#9186f28c59f6e477ab8919312d2654f4f27d45c1"
|
||||
integrity sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==
|
||||
"@typescript-eslint/parser@8.46.1", "@typescript-eslint/parser@^8.46.0":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.1.tgz#81751f46800fc6b01ce1a72760cd17f06e7f395b"
|
||||
integrity sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.1"
|
||||
"@typescript-eslint/types" "8.46.1"
|
||||
"@typescript-eslint/typescript-estree" "8.46.1"
|
||||
"@typescript-eslint/visitor-keys" "8.46.1"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/project-service@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.0.tgz#1190dcc0d3494d46a85773e0c3a2838cbb2b45a7"
|
||||
integrity sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==
|
||||
"@typescript-eslint/project-service@8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.1.tgz#07be0e6f27fa90a17d8e5f6996ee02329c9a8c2e"
|
||||
integrity sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.46.0"
|
||||
"@typescript-eslint/types" "^8.46.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.46.1"
|
||||
"@typescript-eslint/types" "^8.46.1"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz#a41833fe387044075cb2d4cfab490a7f1dd19b61"
|
||||
integrity sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==
|
||||
"@typescript-eslint/scope-manager@8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz#590dd2e65e95af646bdaf50adeae9af39e25e8c1"
|
||||
integrity sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.1"
|
||||
"@typescript-eslint/visitor-keys" "8.46.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.46.0", "@typescript-eslint/tsconfig-utils@^8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz#3e33019e0b94838d37d7cc61341fbcc5bf791007"
|
||||
integrity sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==
|
||||
"@typescript-eslint/tsconfig-utils@8.46.1", "@typescript-eslint/tsconfig-utils@^8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz#24405888560175c6c209c39df11ac06a2efef9d7"
|
||||
integrity sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==
|
||||
|
||||
"@typescript-eslint/type-utils@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz#815efeb11b9533da68fd825628cecf283ac79829"
|
||||
integrity sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==
|
||||
"@typescript-eslint/type-utils@8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz#14d4307dd6045f6b48a888cde1513d6ec305537f"
|
||||
integrity sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/utils" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.1"
|
||||
"@typescript-eslint/typescript-estree" "8.46.1"
|
||||
"@typescript-eslint/utils" "8.46.1"
|
||||
debug "^4.3.4"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/types@8.46.0", "@typescript-eslint/types@^8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.0.tgz#20af6b332f9cd55a15fcd862fdb07d47a6131bf4"
|
||||
integrity sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==
|
||||
"@typescript-eslint/types@8.46.1", "@typescript-eslint/types@^8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.1.tgz#4c5479538ec10b5508b8e982e172911c987446d8"
|
||||
integrity sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz#f45a0d5f5e99b26f0280e8cff3ed3380658fd720"
|
||||
integrity sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==
|
||||
"@typescript-eslint/typescript-estree@8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz#1c146573b942ebe609c156c217ceafdc7a88e6ed"
|
||||
integrity sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.46.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/visitor-keys" "8.46.0"
|
||||
"@typescript-eslint/project-service" "8.46.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.46.1"
|
||||
"@typescript-eslint/types" "8.46.1"
|
||||
"@typescript-eslint/visitor-keys" "8.46.1"
|
||||
debug "^4.3.4"
|
||||
fast-glob "^3.3.2"
|
||||
is-glob "^4.0.3"
|
||||
@@ -4416,22 +4420,22 @@
|
||||
semver "^7.6.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/utils@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.0.tgz#27025c5ed7cbc928440d6a30edd6ba34cc5b927a"
|
||||
integrity sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==
|
||||
"@typescript-eslint/utils@8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.1.tgz#c572184d9227d66b10a954b90249a20c48b22452"
|
||||
integrity sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.7.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/scope-manager" "8.46.1"
|
||||
"@typescript-eslint/types" "8.46.1"
|
||||
"@typescript-eslint/typescript-estree" "8.46.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.46.0":
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz#23936809054c511f703713c56ddd2f46dc197845"
|
||||
integrity sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==
|
||||
"@typescript-eslint/visitor-keys@8.46.1":
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz#da35f1d58ec407419d68847cfd358b32746ac315"
|
||||
integrity sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.46.0"
|
||||
"@typescript-eslint/types" "8.46.1"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -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.30001749:
|
||||
version "1.0.30001749"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz#21a43b923577932097fe32bcaabb6da7f4677632"
|
||||
integrity sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==
|
||||
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.3:
|
||||
version "5.29.3"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.29.3.tgz#a132c3c3c4553c2acd0aca1f02c8484ca4c78183"
|
||||
integrity sha512-cx47SmqrxXCP86+6NHEzXUBEG/MGbNK/H8BQphzUVomxGpG9lZCUo6hIGFNe1i7fP5eaMxpLV/qoqaWVo3TSvw==
|
||||
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"
|
||||
@@ -13590,15 +13594,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.46.0:
|
||||
version "8.46.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.0.tgz#fb1c37a90fadf42fe1c8f8b192b974b6d9c439cc"
|
||||
integrity sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==
|
||||
typescript-eslint@^8.46.1:
|
||||
version "8.46.1"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.1.tgz#baeb322ee83ca566a8cf1f6403847694a3acd44a"
|
||||
integrity sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.46.0"
|
||||
"@typescript-eslint/parser" "8.46.0"
|
||||
"@typescript-eslint/typescript-estree" "8.46.0"
|
||||
"@typescript-eslint/utils" "8.46.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.46.1"
|
||||
"@typescript-eslint/parser" "8.46.1"
|
||||
"@typescript-eslint/typescript-estree" "8.46.1"
|
||||
"@typescript-eslint/utils" "8.46.1"
|
||||
|
||||
typescript@~5.9.3:
|
||||
version "5.9.3"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+153
-98
@@ -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,14 +64124,14 @@
|
||||
"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",
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"math-expression-evaluator": "^2.0.6",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-draggable": "^4.5.0",
|
||||
@@ -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",
|
||||
@@ -65000,6 +65041,20 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/pretty-ms": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||
"integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
|
||||
"dependencies": {
|
||||
"parse-ms": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/re-resizable": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
|
||||
@@ -65936,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",
|
||||
@@ -65950,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",
|
||||
@@ -65994,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",
|
||||
@@ -66084,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",
|
||||
@@ -66097,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"
|
||||
@@ -66178,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",
|
||||
@@ -49,7 +49,7 @@
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"math-expression-evaluator": "^2.0.6",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-js-cron": "^5.2.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,
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
DragEvent,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { styled, typedMemo, usePrevious } from '@superset-ui/core';
|
||||
import { typedMemo, usePrevious } from '@superset-ui/core';
|
||||
import {
|
||||
useTable,
|
||||
usePagination,
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from 'react-table';
|
||||
import { matchSorter, rankings } from 'match-sorter';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Space } from '@superset-ui/core/components';
|
||||
import { Flex, Space } from '@superset-ui/core/components';
|
||||
import GlobalFilter, { GlobalFilterProps } from './components/GlobalFilter';
|
||||
import SelectPageSize, {
|
||||
SelectPageSizeProps,
|
||||
@@ -77,7 +77,7 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
|
||||
sticky?: boolean;
|
||||
rowCount: number;
|
||||
wrapperRef?: MutableRefObject<HTMLDivElement>;
|
||||
onColumnOrderChange: () => void;
|
||||
onColumnOrderChange?: () => void;
|
||||
renderGroupingHeaders?: () => JSX.Element;
|
||||
renderTimeComparisonDropdown?: () => JSX.Element;
|
||||
handleSortByChange: (sortBy: SortByItem[]) => void;
|
||||
@@ -98,24 +98,6 @@ const sortTypes = {
|
||||
alphanumeric: sortAlphanumericCaseInsensitive,
|
||||
};
|
||||
|
||||
const StyledSpace = styled(Space)`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.search-select-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.search-by-label {
|
||||
align-self: center;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledRow = styled.div`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
// Be sure to pass our updateMyData and the skipReset option
|
||||
export default typedMemo(function DataTable<D extends object>({
|
||||
tableClassName,
|
||||
@@ -336,8 +318,7 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
const colToBeMoved = currentCols.splice(columnBeingDragged, 1);
|
||||
currentCols.splice(newPosition, 0, colToBeMoved[0]);
|
||||
setColumnOrder(currentCols);
|
||||
// toggle value in TableChart to trigger column width recalc
|
||||
onColumnOrderChange();
|
||||
onColumnOrderChange?.();
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
@@ -450,30 +431,36 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
>
|
||||
{hasGlobalControl ? (
|
||||
<div ref={globalControlRef} className="form-inline dt-controls">
|
||||
<StyledRow className="row">
|
||||
<StyledSpace size="middle">
|
||||
{hasPagination ? (
|
||||
<SelectPageSize
|
||||
total={resultsSize}
|
||||
current={resultCurrentPageSize}
|
||||
options={pageSizeOptions}
|
||||
selectRenderer={
|
||||
typeof selectPageSize === 'boolean'
|
||||
? undefined
|
||||
: selectPageSize
|
||||
}
|
||||
onChange={setPageSize}
|
||||
/>
|
||||
) : null}
|
||||
<Flex
|
||||
wrap
|
||||
className="row"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
gap="middle"
|
||||
>
|
||||
{hasPagination ? (
|
||||
<SelectPageSize
|
||||
total={resultsSize}
|
||||
current={resultCurrentPageSize}
|
||||
options={pageSizeOptions}
|
||||
selectRenderer={
|
||||
typeof selectPageSize === 'boolean'
|
||||
? undefined
|
||||
: selectPageSize
|
||||
}
|
||||
onChange={setPageSize}
|
||||
/>
|
||||
) : null}
|
||||
<Flex wrap align="center" gap="middle">
|
||||
{serverPagination && (
|
||||
<div className="search-select-container">
|
||||
<span className="search-by-label">Search by: </span>
|
||||
<Space size="small" className="search-select-container">
|
||||
<span className="search-by-label">Search by:</span>
|
||||
<SearchSelectDropdown
|
||||
searchOptions={searchOptions}
|
||||
value={serverPaginationData?.searchColumn || ''}
|
||||
onChange={onSearchColChange}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
{searchInput && (
|
||||
<GlobalFilter<D>
|
||||
@@ -493,8 +480,8 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
{renderTimeComparisonDropdown
|
||||
? renderTimeComparisonDropdown()
|
||||
: null}
|
||||
</StyledSpace>
|
||||
</StyledRow>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
) : null}
|
||||
{wrapStickyTable ? wrapStickyTable(renderTable) : renderTable()}
|
||||
|
||||
@@ -195,6 +195,21 @@ function SortIcon<D extends object>({ column }: { column: ColumnInstance<D> }) {
|
||||
return sortIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label that is visually hidden but accessible
|
||||
*/
|
||||
const VisuallyHidden = styled.label`
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
function SearchInput({
|
||||
count,
|
||||
value,
|
||||
@@ -225,10 +240,10 @@ function SelectPageSize({
|
||||
const { Option } = Select;
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="pageSizeSelect" className="sr-only">
|
||||
<span className="dt-select-page-size">
|
||||
<VisuallyHidden htmlFor="pageSizeSelect">
|
||||
{t('Select page size')}
|
||||
</label>
|
||||
</VisuallyHidden>
|
||||
{t('Show')}{' '}
|
||||
<Select<number>
|
||||
id="pageSizeSelect"
|
||||
@@ -252,7 +267,7 @@ function SelectPageSize({
|
||||
})}
|
||||
</Select>{' '}
|
||||
{t('entries per page')}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -296,12 +311,17 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
serverPageLength,
|
||||
slice_id,
|
||||
} = props;
|
||||
const comparisonColumns = [
|
||||
{ key: 'all', label: t('Display all') },
|
||||
{ key: '#', label: '#' },
|
||||
{ key: '△', label: '△' },
|
||||
{ key: '%', label: '%' },
|
||||
];
|
||||
|
||||
const comparisonColumns = useMemo(
|
||||
() => [
|
||||
{ key: 'all', label: t('Display all') },
|
||||
{ key: '#', label: '#' },
|
||||
{ key: '△', label: '△' },
|
||||
{ key: '%', label: '%' },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const timestampFormatter = useCallback(
|
||||
value => getTimeFormatterForGranularity(timeGrain)(value),
|
||||
[timeGrain],
|
||||
@@ -353,71 +373,74 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[filters],
|
||||
);
|
||||
|
||||
const getCrossFilterDataMask = (key: string, value: DataRecordValue) => {
|
||||
let updatedFilters = { ...(filters || {}) };
|
||||
if (filters && isActiveFilterValue(key, value)) {
|
||||
updatedFilters = {};
|
||||
} else {
|
||||
updatedFilters = {
|
||||
[key]: [value],
|
||||
};
|
||||
}
|
||||
if (
|
||||
Array.isArray(updatedFilters[key]) &&
|
||||
updatedFilters[key].length === 0
|
||||
) {
|
||||
delete updatedFilters[key];
|
||||
}
|
||||
|
||||
const groupBy = Object.keys(updatedFilters);
|
||||
const groupByValues = Object.values(updatedFilters);
|
||||
const labelElements: string[] = [];
|
||||
groupBy.forEach(col => {
|
||||
const isTimestamp = col === DTTM_ALIAS;
|
||||
const filterValues = ensureIsArray(updatedFilters?.[col]);
|
||||
if (filterValues.length) {
|
||||
const valueLabels = filterValues.map(value =>
|
||||
isTimestamp ? timestampFormatter(value) : value,
|
||||
);
|
||||
labelElements.push(`${valueLabels.join(', ')}`);
|
||||
const getCrossFilterDataMask = useCallback(
|
||||
(key: string, value: DataRecordValue) => {
|
||||
let updatedFilters = { ...(filters || {}) };
|
||||
if (filters && isActiveFilterValue(key, value)) {
|
||||
updatedFilters = {};
|
||||
} else {
|
||||
updatedFilters = {
|
||||
[key]: [value],
|
||||
};
|
||||
}
|
||||
if (
|
||||
Array.isArray(updatedFilters[key]) &&
|
||||
updatedFilters[key].length === 0
|
||||
) {
|
||||
delete updatedFilters[key];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters:
|
||||
groupBy.length === 0
|
||||
? []
|
||||
: groupBy.map(col => {
|
||||
const val = ensureIsArray(updatedFilters?.[col]);
|
||||
if (!val.length)
|
||||
const groupBy = Object.keys(updatedFilters);
|
||||
const groupByValues = Object.values(updatedFilters);
|
||||
const labelElements: string[] = [];
|
||||
groupBy.forEach(col => {
|
||||
const isTimestamp = col === DTTM_ALIAS;
|
||||
const filterValues = ensureIsArray(updatedFilters?.[col]);
|
||||
if (filterValues.length) {
|
||||
const valueLabels = filterValues.map(value =>
|
||||
isTimestamp ? timestampFormatter(value) : value,
|
||||
);
|
||||
labelElements.push(`${valueLabels.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters:
|
||||
groupBy.length === 0
|
||||
? []
|
||||
: groupBy.map(col => {
|
||||
const val = ensureIsArray(updatedFilters?.[col]);
|
||||
if (!val.length)
|
||||
return {
|
||||
col,
|
||||
op: 'IS NULL' as const,
|
||||
};
|
||||
return {
|
||||
col,
|
||||
op: 'IS NULL' as const,
|
||||
op: 'IN' as const,
|
||||
val: val.map(el =>
|
||||
el instanceof Date ? el.getTime() : el!,
|
||||
),
|
||||
grain: col === DTTM_ALIAS ? timeGrain : undefined,
|
||||
};
|
||||
return {
|
||||
col,
|
||||
op: 'IN' as const,
|
||||
val: val.map(el =>
|
||||
el instanceof Date ? el.getTime() : el!,
|
||||
),
|
||||
grain: col === DTTM_ALIAS ? timeGrain : undefined,
|
||||
};
|
||||
}),
|
||||
}),
|
||||
},
|
||||
filterState: {
|
||||
label: labelElements.join(', '),
|
||||
value: groupByValues.length ? groupByValues : null,
|
||||
filters:
|
||||
updatedFilters && Object.keys(updatedFilters).length
|
||||
? updatedFilters
|
||||
: null,
|
||||
},
|
||||
},
|
||||
filterState: {
|
||||
label: labelElements.join(', '),
|
||||
value: groupByValues.length ? groupByValues : null,
|
||||
filters:
|
||||
updatedFilters && Object.keys(updatedFilters).length
|
||||
? updatedFilters
|
||||
: null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: isActiveFilterValue(key, value),
|
||||
};
|
||||
};
|
||||
isCurrentValueSelected: isActiveFilterValue(key, value),
|
||||
};
|
||||
},
|
||||
[filters, isActiveFilterValue, timestampFormatter, timeGrain],
|
||||
);
|
||||
|
||||
const toggleFilter = useCallback(
|
||||
function toggleFilter(key: string, val: DataRecordValue) {
|
||||
@@ -429,17 +452,21 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[emitCrossFilters, getCrossFilterDataMask, setDataMask],
|
||||
);
|
||||
|
||||
const getSharedStyle = (column: DataColumnMeta): CSSProperties => {
|
||||
const { isNumeric, config = {} } = column;
|
||||
const textAlign =
|
||||
config.horizontalAlign ||
|
||||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
|
||||
return {
|
||||
textAlign,
|
||||
};
|
||||
};
|
||||
const getSharedStyle = useCallback(
|
||||
(column: DataColumnMeta): CSSProperties => {
|
||||
const { isNumeric, config = {} } = column;
|
||||
const textAlign =
|
||||
config.horizontalAlign ||
|
||||
(isNumeric && !isUsingTimeComparison ? 'right' : 'left');
|
||||
return {
|
||||
textAlign,
|
||||
};
|
||||
},
|
||||
[isUsingTimeComparison],
|
||||
);
|
||||
|
||||
const comparisonLabels = useMemo(() => [t('Main'), '#', '△', '%'], []);
|
||||
|
||||
const comparisonLabels = [t('Main'), '#', '△', '%'];
|
||||
const filteredColumnsMeta = useMemo(() => {
|
||||
if (!isUsingTimeComparison) {
|
||||
return columnsMeta;
|
||||
@@ -471,79 +498,86 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
selectedComparisonColumns,
|
||||
]);
|
||||
|
||||
const handleContextMenu =
|
||||
onContextMenu && !isRawRecords
|
||||
? (
|
||||
value: D,
|
||||
cellPoint: {
|
||||
key: string;
|
||||
value: DataRecordValue;
|
||||
isMetric?: boolean;
|
||||
},
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
filteredColumnsMeta.forEach(col => {
|
||||
if (!col.isMetric) {
|
||||
const dataRecordValue = value[col.key];
|
||||
drillToDetailFilters.push({
|
||||
col: col.key,
|
||||
op: '==',
|
||||
val: dataRecordValue as string | number | boolean,
|
||||
formattedVal: formatColumnValue(col, dataRecordValue)[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
onContextMenu(clientX, clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
crossFilter: cellPoint.isMetric
|
||||
? undefined
|
||||
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
|
||||
drillBy: cellPoint.isMetric
|
||||
? undefined
|
||||
: {
|
||||
filters: [
|
||||
{
|
||||
col: cellPoint.key,
|
||||
op: '==',
|
||||
val: cellPoint.value as string | number | boolean,
|
||||
},
|
||||
],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const getHeaderColumns = (
|
||||
columnsMeta: DataColumnMeta[],
|
||||
enableTimeComparison?: boolean,
|
||||
) => {
|
||||
const resultMap: Record<string, number[]> = {};
|
||||
|
||||
if (!enableTimeComparison) {
|
||||
return resultMap;
|
||||
const handleContextMenu = useMemo(() => {
|
||||
if (onContextMenu && !isRawRecords) {
|
||||
return (
|
||||
value: D,
|
||||
cellPoint: {
|
||||
key: string;
|
||||
value: DataRecordValue;
|
||||
isMetric?: boolean;
|
||||
},
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
filteredColumnsMeta.forEach(col => {
|
||||
if (!col.isMetric) {
|
||||
const dataRecordValue = value[col.key];
|
||||
drillToDetailFilters.push({
|
||||
col: col.key,
|
||||
op: '==',
|
||||
val: dataRecordValue as string | number | boolean,
|
||||
formattedVal: formatColumnValue(col, dataRecordValue)[1],
|
||||
});
|
||||
}
|
||||
});
|
||||
onContextMenu(clientX, clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
crossFilter: cellPoint.isMetric
|
||||
? undefined
|
||||
: getCrossFilterDataMask(cellPoint.key, cellPoint.value),
|
||||
drillBy: cellPoint.isMetric
|
||||
? undefined
|
||||
: {
|
||||
filters: [
|
||||
{
|
||||
col: cellPoint.key,
|
||||
op: '==',
|
||||
val: cellPoint.value as string | number | boolean,
|
||||
},
|
||||
],
|
||||
groupbyFieldName: 'groupby',
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [
|
||||
onContextMenu,
|
||||
isRawRecords,
|
||||
filteredColumnsMeta,
|
||||
getCrossFilterDataMask,
|
||||
]);
|
||||
|
||||
columnsMeta.forEach((element, index) => {
|
||||
// Check if element's label is one of the comparison labels
|
||||
if (comparisonLabels.includes(element.label)) {
|
||||
// Extract the key portion after the space, assuming the format is always "label key"
|
||||
const keyPortion = element.key.substring(element.label.length);
|
||||
const getHeaderColumns = useCallback(
|
||||
(columnsMeta: DataColumnMeta[], enableTimeComparison?: boolean) => {
|
||||
const resultMap: Record<string, number[]> = {};
|
||||
|
||||
// If the key portion is not in the map, initialize it with the current index
|
||||
if (!resultMap[keyPortion]) {
|
||||
resultMap[keyPortion] = [index];
|
||||
} else {
|
||||
// Add the index to the existing array
|
||||
resultMap[keyPortion].push(index);
|
||||
}
|
||||
if (!enableTimeComparison) {
|
||||
return resultMap;
|
||||
}
|
||||
});
|
||||
|
||||
return resultMap;
|
||||
};
|
||||
columnsMeta.forEach((element, index) => {
|
||||
// Check if element's label is one of the comparison labels
|
||||
if (comparisonLabels.includes(element.label)) {
|
||||
// Extract the key portion after the space, assuming the format is always "label key"
|
||||
const keyPortion = element.key.substring(element.label.length);
|
||||
|
||||
// If the key portion is not in the map, initialize it with the current index
|
||||
if (!resultMap[keyPortion]) {
|
||||
resultMap[keyPortion] = [index];
|
||||
} else {
|
||||
// Add the index to the existing array
|
||||
resultMap[keyPortion].push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return resultMap;
|
||||
},
|
||||
[comparisonLabels],
|
||||
);
|
||||
|
||||
const renderTimeComparisonDropdown = (): JSX.Element => {
|
||||
const allKey = comparisonColumns[0].key;
|
||||
@@ -638,6 +672,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
);
|
||||
};
|
||||
|
||||
const groupHeaderColumns = useMemo(
|
||||
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
|
||||
[filteredColumnsMeta, getHeaderColumns, isUsingTimeComparison],
|
||||
);
|
||||
|
||||
const renderGroupingHeaders = (): JSX.Element => {
|
||||
// TODO: Make use of ColumnGroup to render the aditional headers
|
||||
const headers: any = [];
|
||||
@@ -719,11 +758,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
);
|
||||
};
|
||||
|
||||
const groupHeaderColumns = useMemo(
|
||||
() => getHeaderColumns(filteredColumnsMeta, isUsingTimeComparison),
|
||||
[filteredColumnsMeta, isUsingTimeComparison],
|
||||
);
|
||||
|
||||
const getColumnConfigs = useCallback(
|
||||
(
|
||||
column: DataColumnMeta,
|
||||
@@ -1086,19 +1120,27 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
},
|
||||
[
|
||||
getSharedStyle,
|
||||
defaultAlignPN,
|
||||
defaultColorPN,
|
||||
emitCrossFilters,
|
||||
getValueRange,
|
||||
isActiveFilterValue,
|
||||
isRawRecords,
|
||||
showCellBars,
|
||||
sortDesc,
|
||||
toggleFilter,
|
||||
totals,
|
||||
columnColorFormatters,
|
||||
columnOrderToggle,
|
||||
isUsingTimeComparison,
|
||||
basicColorFormatters,
|
||||
showCellBars,
|
||||
isRawRecords,
|
||||
getValueRange,
|
||||
emitCrossFilters,
|
||||
comparisonLabels,
|
||||
totals,
|
||||
theme,
|
||||
sortDesc,
|
||||
groupHeaderColumns,
|
||||
allowRenderHtml,
|
||||
basicColorColumnFormatters,
|
||||
isActiveFilterValue,
|
||||
toggleFilter,
|
||||
handleContextMenu,
|
||||
allowRearrangeColumns,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1131,7 +1173,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
if (!isEqual(options, searchOptions)) {
|
||||
setSearchOptions(options || []);
|
||||
}
|
||||
}, [columns]);
|
||||
}, [columns, searchOptions]);
|
||||
|
||||
const handleServerPaginationChange = useCallback(
|
||||
(pageNumber: number, pageSize: number) => {
|
||||
@@ -1142,7 +1184,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
[setDataMask],
|
||||
[serverPaginationData, setDataMask],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1154,7 +1196,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
}
|
||||
}, []);
|
||||
}, [
|
||||
hasServerPageLengthChanged,
|
||||
serverPageLength,
|
||||
serverPaginationData,
|
||||
setDataMask,
|
||||
]);
|
||||
|
||||
const handleSizeChange = useCallback(
|
||||
({ width, height }: { width: number; height: number }) => {
|
||||
@@ -1200,7 +1247,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
};
|
||||
updateTableOwnState(setDataMask, modifiedOwnState);
|
||||
},
|
||||
[setDataMask, serverPagination],
|
||||
[serverPagination, serverPaginationData, setDataMask],
|
||||
);
|
||||
|
||||
const handleSearch = (searchText: string) => {
|
||||
|
||||
+49
-30
@@ -16,6 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DatasourceType } from '@superset-ui/core';
|
||||
|
||||
export const id = 7;
|
||||
export const datasourceId = `${id}__table`;
|
||||
|
||||
@@ -40,125 +42,135 @@ export default {
|
||||
},
|
||||
metrics: [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'metric-1-uuid',
|
||||
expression: 'SUM(birth_names.num)',
|
||||
warning_text: null,
|
||||
verbose_name: 'sum__num',
|
||||
metric_name: 'sum__num',
|
||||
description: null,
|
||||
metric_type: 'sum',
|
||||
certified_by: 'someone',
|
||||
certification_details: 'foo',
|
||||
warning_markdown: 'bar',
|
||||
extra:
|
||||
'{"certification":{"details":"foo", "certified_by":"someone"},"warning_markdown":"bar"}',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'metric-2-uuid',
|
||||
expression: 'AVG(birth_names.num)',
|
||||
warning_text: null,
|
||||
verbose_name: 'avg__num',
|
||||
metric_name: 'avg__num',
|
||||
description: null,
|
||||
metric_type: 'avg',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: 'metric-3-uuid',
|
||||
expression: 'SUM(birth_names.num_boys)',
|
||||
warning_text: null,
|
||||
verbose_name: 'sum__num_boys',
|
||||
metric_name: 'sum__num_boys',
|
||||
description: null,
|
||||
metric_type: 'sum',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
uuid: 'metric-4-uuid',
|
||||
expression: 'AVG(birth_names.num_boys)',
|
||||
warning_text: null,
|
||||
verbose_name: 'avg__num_boys',
|
||||
metric_name: 'avg__num_boys',
|
||||
description: null,
|
||||
metric_type: 'avg',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
uuid: 'metric-5-uuid',
|
||||
expression: 'SUM(birth_names.num_girls)',
|
||||
warning_text: null,
|
||||
verbose_name: 'sum__num_girls',
|
||||
metric_name: 'sum__num_girls',
|
||||
description: null,
|
||||
metric_type: 'sum',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
uuid: 'metric-6-uuid',
|
||||
expression: 'AVG(birth_names.num_girls)',
|
||||
warning_text: null,
|
||||
verbose_name: 'avg__num_girls',
|
||||
metric_name: 'avg__num_girls',
|
||||
description: null,
|
||||
metric_type: 'avg',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
uuid: 'metric-7-uuid',
|
||||
expression: 'COUNT(*)',
|
||||
warning_text: null,
|
||||
verbose_name: 'COUNT(*)',
|
||||
metric_name: 'count',
|
||||
description: null,
|
||||
metric_type: 'count',
|
||||
},
|
||||
],
|
||||
column_formats: {},
|
||||
columns: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'DATETIME',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: true,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'ds',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'VARCHAR(16)',
|
||||
description: null,
|
||||
filterable: true,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: true,
|
||||
column_name: 'gender',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'VARCHAR(255)',
|
||||
description: null,
|
||||
filterable: true,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: true,
|
||||
column_name: 'name',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'BIGINT',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'num',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'VARCHAR(10)',
|
||||
description: null,
|
||||
filterable: true,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: true,
|
||||
column_name: 'state',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
type: 'BIGINT',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'num_boys',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
type: 'BIGINT',
|
||||
description: null,
|
||||
filterable: false,
|
||||
verbose_name: null,
|
||||
is_dttm: false,
|
||||
is_active: true,
|
||||
expression: '',
|
||||
groupby: false,
|
||||
column_name: 'num_girls',
|
||||
@@ -169,7 +181,9 @@ export default {
|
||||
granularity_sqla: [['ds', 'ds']],
|
||||
main_dttm_col: 'ds',
|
||||
name: 'birth_names',
|
||||
owners: [{ first_name: 'joe', last_name: 'man', id: 1 }],
|
||||
owners: [
|
||||
{ first_name: 'joe', last_name: 'man', id: 1, username: 'joeman' },
|
||||
],
|
||||
database: {
|
||||
name: 'main',
|
||||
backend: 'sqlite',
|
||||
@@ -198,6 +212,11 @@ export default {
|
||||
['["num_girls", true]', 'num_girls [asc]'],
|
||||
['["num_girls", false]', 'num_girls [desc]'],
|
||||
],
|
||||
type: 'table',
|
||||
type: DatasourceType.Table,
|
||||
description: null,
|
||||
is_managed_externally: false,
|
||||
normalize_columns: false,
|
||||
always_filter_main_dttm: false,
|
||||
datasource_name: null,
|
||||
},
|
||||
};
|
||||
+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
|
||||
|
||||
+55
-25
@@ -25,7 +25,8 @@ import {
|
||||
cleanup,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { DatasourceType, isFeatureEnabled } from '@superset-ui/core';
|
||||
import type { DatasetObject } from 'src/features/datasets/types';
|
||||
import DatasourceEditor from '..';
|
||||
|
||||
/* eslint-disable jest/no-export */
|
||||
@@ -34,8 +35,17 @@ jest.mock('@superset-ui/core', () => ({
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
interface DatasourceEditorProps {
|
||||
datasource: DatasetObject;
|
||||
addSuccessToast: () => void;
|
||||
addDangerToast: () => void;
|
||||
onChange: jest.Mock;
|
||||
columnLabels?: Record<string, string>;
|
||||
columnLabelTooltips?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Common setup for tests
|
||||
export const props = {
|
||||
export const props: DatasourceEditorProps = {
|
||||
datasource: mockDatasource['7__table'],
|
||||
addSuccessToast: () => {},
|
||||
addDangerToast: () => {},
|
||||
@@ -47,16 +57,19 @@ export const props = {
|
||||
state: 'This is a tooltip for state',
|
||||
},
|
||||
};
|
||||
|
||||
export const DATASOURCE_ENDPOINT =
|
||||
'glob:*/datasource/external_metadata_by_name/*';
|
||||
|
||||
const routeProps = {
|
||||
history: {},
|
||||
location: {},
|
||||
match: {},
|
||||
};
|
||||
export const asyncRender = props =>
|
||||
|
||||
export const asyncRender = (renderProps: DatasourceEditorProps) =>
|
||||
waitFor(() =>
|
||||
render(<DatasourceEditor {...props} {...routeProps} />, {
|
||||
render(<DatasourceEditor {...renderProps} {...routeProps} />, {
|
||||
useRedux: true,
|
||||
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
|
||||
useRouter: true,
|
||||
@@ -87,16 +100,16 @@ describe('DatasourceEditor', () => {
|
||||
|
||||
test('can sync columns from source', async () => {
|
||||
const columnsTab = screen.getByTestId('collection-tab-Columns');
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const syncButton = screen.getByText(/sync columns from source/i);
|
||||
expect(syncButton).toBeInTheDocument();
|
||||
|
||||
// Use a Promise to track when fetchMock is called
|
||||
const fetchPromise = new Promise(resolve => {
|
||||
const fetchPromise = new Promise<string>(resolve => {
|
||||
fetchMock.get(
|
||||
DATASOURCE_ENDPOINT,
|
||||
url => {
|
||||
(url: string) => {
|
||||
resolve(url);
|
||||
return [];
|
||||
},
|
||||
@@ -104,7 +117,7 @@ describe('DatasourceEditor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
userEvent.click(syncButton);
|
||||
await userEvent.click(syncButton);
|
||||
|
||||
// Wait for the fetch to be called
|
||||
const url = await fetchPromise;
|
||||
@@ -114,12 +127,12 @@ describe('DatasourceEditor', () => {
|
||||
// to add, remove and modify columns accordingly
|
||||
test('can modify columns', async () => {
|
||||
const columnsTab = screen.getByTestId('collection-tab-Columns');
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const getToggles = screen.getAllByRole('button', {
|
||||
name: /expand row/i,
|
||||
});
|
||||
userEvent.click(getToggles[0]);
|
||||
await userEvent.click(getToggles[0]);
|
||||
|
||||
const getTextboxes = await screen.findAllByRole('textbox');
|
||||
expect(getTextboxes.length).toBeGreaterThanOrEqual(5);
|
||||
@@ -132,22 +145,39 @@ describe('DatasourceEditor', () => {
|
||||
'Certification details',
|
||||
);
|
||||
|
||||
userEvent.type(inputLabel, 'test_label');
|
||||
userEvent.type(inputDescription, 'test');
|
||||
userEvent.type(inputDtmFormat, 'test');
|
||||
userEvent.type(inputCertifiedBy, 'test');
|
||||
userEvent.type(inputCertDetails, 'test');
|
||||
// Clear onChange mock to track user action callbacks
|
||||
props.onChange.mockClear();
|
||||
|
||||
await userEvent.type(inputLabel, 'test_label');
|
||||
await userEvent.type(inputDescription, 'test');
|
||||
await userEvent.type(inputDtmFormat, 'test');
|
||||
await userEvent.type(inputCertifiedBy, 'test');
|
||||
await userEvent.type(inputCertDetails, 'test');
|
||||
|
||||
// Verify the inputs were updated with the typed values
|
||||
await waitFor(() => {
|
||||
expect(inputLabel).toHaveValue('test_label');
|
||||
expect(inputDescription).toHaveValue('test');
|
||||
expect(inputDtmFormat).toHaveValue('test');
|
||||
expect(inputCertifiedBy).toHaveValue('test');
|
||||
expect(inputCertDetails).toHaveValue('test');
|
||||
});
|
||||
|
||||
// Verify that onChange was triggered by user actions
|
||||
await waitFor(() => {
|
||||
expect(props.onChange).toHaveBeenCalled();
|
||||
});
|
||||
}, 40000);
|
||||
|
||||
test('can delete columns', async () => {
|
||||
const columnsTab = screen.getByTestId('collection-tab-Columns');
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const getToggles = screen.getAllByRole('button', {
|
||||
name: /expand row/i,
|
||||
});
|
||||
|
||||
userEvent.click(getToggles[0]);
|
||||
await userEvent.click(getToggles[0]);
|
||||
|
||||
const deleteButtons = await screen.findAllByRole('button', {
|
||||
name: /delete item/i,
|
||||
@@ -155,7 +185,7 @@ describe('DatasourceEditor', () => {
|
||||
const initialCount = deleteButtons.length;
|
||||
expect(initialCount).toBeGreaterThan(0);
|
||||
|
||||
userEvent.click(deleteButtons[0]);
|
||||
await userEvent.click(deleteButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const countRows = screen.getAllByRole('button', { name: /delete item/i });
|
||||
@@ -165,14 +195,14 @@ describe('DatasourceEditor', () => {
|
||||
|
||||
test('can add new columns', async () => {
|
||||
const calcColsTab = screen.getByTestId('collection-tab-Calculated columns');
|
||||
userEvent.click(calcColsTab);
|
||||
await userEvent.click(calcColsTab);
|
||||
|
||||
const addBtn = screen.getByRole('button', {
|
||||
name: /add item/i,
|
||||
});
|
||||
expect(addBtn).toBeInTheDocument();
|
||||
|
||||
userEvent.click(addBtn);
|
||||
await userEvent.click(addBtn);
|
||||
|
||||
// newColumn (Column name) is the first textbox in the tab
|
||||
await waitFor(() => {
|
||||
@@ -185,7 +215,7 @@ describe('DatasourceEditor', () => {
|
||||
const columnsTab = screen.getByRole('tab', {
|
||||
name: /settings/i,
|
||||
});
|
||||
userEvent.click(columnsTab);
|
||||
await userEvent.click(columnsTab);
|
||||
|
||||
const extraField = screen.getAllByText(/extra/i);
|
||||
expect(extraField.length).toBeGreaterThan(0);
|
||||
@@ -199,7 +229,7 @@ describe('DatasourceEditor', () => {
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('DatasourceEditor Source Tab', () => {
|
||||
beforeAll(() => {
|
||||
isFeatureEnabled.mockImplementation(() => false);
|
||||
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -215,12 +245,12 @@ describe('DatasourceEditor Source Tab', () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
isFeatureEnabled.mockRestore();
|
||||
(isFeatureEnabled as jest.Mock).mockRestore();
|
||||
});
|
||||
|
||||
test('Source Tab: edit mode', async () => {
|
||||
const getLockBtn = screen.getByRole('img', { name: /lock/i });
|
||||
userEvent.click(getLockBtn);
|
||||
await userEvent.click(getLockBtn);
|
||||
|
||||
const physicalRadioBtn = screen.getByRole('radio', {
|
||||
name: /physical \(table or view\)/i,
|
||||
@@ -259,7 +289,7 @@ describe('DatasourceEditor Source Tab', () => {
|
||||
datasource: {
|
||||
...props.datasource,
|
||||
table_name: 'Vehicle Sales +',
|
||||
datasourceType: 'virtual',
|
||||
type: DatasourceType.Query,
|
||||
sql: 'SELECT * FROM users',
|
||||
},
|
||||
});
|
||||
+20
-14
@@ -19,13 +19,16 @@
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { DatasetObject } from 'src/features/datasets/types';
|
||||
import DatasourceEditor from '..';
|
||||
import { props, DATASOURCE_ENDPOINT } from './DatasourceEditor.test';
|
||||
|
||||
type MetricType = DatasetObject['metrics'][number];
|
||||
|
||||
// Optimized render function that doesn't use waitFor initially
|
||||
// This helps prevent one source of the timeout
|
||||
const fastRender = props =>
|
||||
render(<DatasourceEditor {...props} />, {
|
||||
const fastRender = (renderProps: typeof props) =>
|
||||
render(<DatasourceEditor {...renderProps} />, {
|
||||
useRedux: true,
|
||||
initialState: { common: { currencies: ['USD', 'GBP', 'EUR'] } },
|
||||
});
|
||||
@@ -66,13 +69,15 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
const metricButton = screen.getByTestId('collection-tab-Metrics');
|
||||
await userEvent.click(metricButton);
|
||||
|
||||
// Find and expand the first metric row
|
||||
// Find and expand the metric row with currency
|
||||
// Metrics are sorted by ID descending, so metric with id=1 (which has currency)
|
||||
// is at position 6 (last). Expand that one.
|
||||
const expandToggles = await screen.findAllByLabelText(
|
||||
/expand row/i,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
await userEvent.click(expandToggles[0]);
|
||||
await userEvent.click(expandToggles[6]);
|
||||
|
||||
// Check for currency section header
|
||||
const currencyHeader = await screen.findByText(
|
||||
@@ -91,7 +96,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
expect(positionSelector).toBeInTheDocument();
|
||||
|
||||
// Open the dropdown
|
||||
userEvent.click(positionSelector);
|
||||
await userEvent.click(positionSelector);
|
||||
|
||||
// Wait for dropdown to open and find the suffix option
|
||||
const suffixOption = await waitFor(
|
||||
@@ -99,7 +104,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// Look for 'suffix' option in the dropdown
|
||||
const options = document.querySelectorAll('.ant-select-item-option');
|
||||
const suffixOpt = Array.from(options).find(opt =>
|
||||
opt.textContent.toLowerCase().includes('suffix'),
|
||||
opt.textContent?.toLowerCase().includes('suffix'),
|
||||
);
|
||||
|
||||
if (!suffixOpt) throw new Error('Suffix option not found');
|
||||
@@ -112,7 +117,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
propsWithCurrency.onChange.mockClear();
|
||||
|
||||
// Click the suffix option
|
||||
userEvent.click(suffixOption);
|
||||
await userEvent.click(suffixOption);
|
||||
|
||||
// Check if onChange was called with the expected parameters
|
||||
await waitFor(
|
||||
@@ -123,11 +128,12 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// More robust check for the metrics array
|
||||
const metrics = callArg.metrics || [];
|
||||
const updatedMetric = metrics.find(
|
||||
m => m.currency && m.currency.symbolPosition === 'suffix',
|
||||
(m: MetricType) =>
|
||||
m.currency && m.currency.symbolPosition === 'suffix',
|
||||
);
|
||||
|
||||
expect(updatedMetric).toBeDefined();
|
||||
expect(updatedMetric.currency.symbol).toBe('USD');
|
||||
expect(updatedMetric?.currency?.symbol).toBe('USD');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
@@ -142,7 +148,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
);
|
||||
|
||||
// Open the currency dropdown
|
||||
userEvent.click(currencySymbol);
|
||||
await userEvent.click(currencySymbol);
|
||||
|
||||
// Wait for dropdown to open and find the GBP option
|
||||
const gbpOption = await waitFor(
|
||||
@@ -150,7 +156,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// Look for 'GBP' option in the dropdown
|
||||
const options = document.querySelectorAll('.ant-select-item-option');
|
||||
const gbpOpt = Array.from(options).find(opt =>
|
||||
opt.textContent.includes('GBP'),
|
||||
opt.textContent?.includes('GBP'),
|
||||
);
|
||||
|
||||
if (!gbpOpt) throw new Error('GBP option not found');
|
||||
@@ -163,7 +169,7 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
propsWithCurrency.onChange.mockClear();
|
||||
|
||||
// Click the GBP option
|
||||
userEvent.click(gbpOption);
|
||||
await userEvent.click(gbpOption);
|
||||
|
||||
// Verify the onChange with GBP was called
|
||||
await waitFor(
|
||||
@@ -174,11 +180,11 @@ describe('DatasourceEditor Currency Tests', () => {
|
||||
// More robust check
|
||||
const metrics = callArg.metrics || [];
|
||||
const updatedMetric = metrics.find(
|
||||
m => m.currency && m.currency.symbol === 'GBP',
|
||||
(m: MetricType) => m.currency && m.currency.symbol === 'GBP',
|
||||
);
|
||||
|
||||
expect(updatedMetric).toBeDefined();
|
||||
expect(updatedMetric.currency.symbolPosition).toBe('suffix');
|
||||
expect(updatedMetric?.currency?.symbolPosition).toBe('suffix');
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
+9
-6
@@ -42,15 +42,18 @@ describe('DatasourceEditor RTL Metrics Tests', () => {
|
||||
await userEvent.click(metricButton);
|
||||
|
||||
const expandToggle = await screen.findAllByLabelText(/expand row/i);
|
||||
await userEvent.click(expandToggle[0]);
|
||||
// Metrics are sorted by ID descending, so metric with id=1 (which has certification)
|
||||
// is at position 6 (last). Expand that one.
|
||||
await userEvent.click(expandToggle[6]);
|
||||
|
||||
// Wait for fields to appear
|
||||
const certificationDetails = await screen.findByPlaceholderText(
|
||||
/certification details/i,
|
||||
);
|
||||
expect(certificationDetails.value).toEqual('foo');
|
||||
const certifiedBy = await screen.findByPlaceholderText(/certified by/i);
|
||||
|
||||
const warningMarkdown = await screen.findByPlaceholderText(/certified by/i);
|
||||
expect(warningMarkdown.value).toEqual('someone');
|
||||
expect(certificationDetails).toHaveValue('foo');
|
||||
expect(certifiedBy).toHaveValue('someone');
|
||||
});
|
||||
|
||||
test('properly updates the metric information', async () => {
|
||||
@@ -71,14 +74,14 @@ describe('DatasourceEditor RTL Metrics Tests', () => {
|
||||
/certification details/i,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(certifiedBy.value).toEqual('I am typing a new name');
|
||||
expect(certifiedBy).toHaveValue('I am typing a new name');
|
||||
});
|
||||
|
||||
await userEvent.clear(certificationDetails);
|
||||
await userEvent.type(certificationDetails, 'I am typing something new');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(certificationDetails.value).toEqual('I am typing something new');
|
||||
expect(certificationDetails).toHaveValue('I am typing something new');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -16,12 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
FeatureFlag,
|
||||
SupersetClient,
|
||||
isFeatureEnabled,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import { SupersetClient, logging } from '@superset-ui/core';
|
||||
import type { contributions, core } from '@apache-superset/core';
|
||||
import { ExtensionContext } from '../core/models';
|
||||
|
||||
@@ -62,9 +57,6 @@ class ExtensionsManager {
|
||||
* @throws Error if initialization fails.
|
||||
*/
|
||||
public async initializeExtensions(): Promise<void> {
|
||||
if (!isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
return;
|
||||
}
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: '/api/v1/extensions/',
|
||||
});
|
||||
|
||||
@@ -17,10 +17,21 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { logging } from '@superset-ui/core';
|
||||
import { logging, FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import ExtensionsStartup from './ExtensionsStartup';
|
||||
import ExtensionsManager from './ExtensionsManager';
|
||||
|
||||
// Mock the isFeatureEnabled function
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
|
||||
typeof isFeatureEnabled
|
||||
>;
|
||||
|
||||
const mockInitialState = {
|
||||
user: { userId: 1 },
|
||||
};
|
||||
@@ -36,12 +47,26 @@ beforeEach(() => {
|
||||
|
||||
// Clear any existing ExtensionsManager instance
|
||||
(ExtensionsManager as any).instance = undefined;
|
||||
|
||||
// Reset feature flag mock to enabled by default
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
mockIsFeatureEnabled.mockReturnValue(true);
|
||||
|
||||
// Setup fetch mocks for API calls
|
||||
fetchMock.restore();
|
||||
fetchMock.get('glob:*/api/v1/extensions/', {
|
||||
result: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
delete (window as any).superset;
|
||||
(ExtensionsManager as any).instance = undefined;
|
||||
|
||||
// Reset mocks
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
test('renders without crashing', () => {
|
||||
@@ -55,6 +80,12 @@ test('renders without crashing', () => {
|
||||
});
|
||||
|
||||
test('sets up global superset object when user is logged in', async () => {
|
||||
// Mock initializeExtensions to avoid API calls in this test
|
||||
const manager = ExtensionsManager.getInstance();
|
||||
const initializeSpy = jest
|
||||
.spyOn(manager, 'initializeExtensions')
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
initialState: mockInitialState,
|
||||
@@ -70,6 +101,8 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
expect((window as any).superset.extensions).toBeDefined();
|
||||
expect((window as any).superset.sqlLab).toBeDefined();
|
||||
});
|
||||
|
||||
initializeSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not set up global superset object when user is not logged in', async () => {
|
||||
@@ -85,18 +118,26 @@ test('does not set up global superset object when user is not logged in', async
|
||||
});
|
||||
|
||||
test('initializes ExtensionsManager when user is logged in', async () => {
|
||||
// Mock initializeExtensions to avoid API calls, but track that it was called
|
||||
const manager = ExtensionsManager.getInstance();
|
||||
const initializeSpy = jest
|
||||
.spyOn(manager, 'initializeExtensions')
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify ExtensionsManager has been initialized by checking if it has extensions loaded
|
||||
const manager = ExtensionsManager.getInstance();
|
||||
// Verify ExtensionsManager initialization was called
|
||||
expect(initializeSpy).toHaveBeenCalledTimes(1);
|
||||
// The manager should exist and be ready to use
|
||||
expect(manager).toBeDefined();
|
||||
expect(manager.getExtensions).toBeDefined();
|
||||
});
|
||||
|
||||
initializeSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not initialize ExtensionsManager when user is not logged in', async () => {
|
||||
@@ -114,61 +155,6 @@ test('does not initialize ExtensionsManager when user is not logged in', async (
|
||||
});
|
||||
});
|
||||
|
||||
test('handles ExtensionsManager initialization errors gracefully', async () => {
|
||||
const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
|
||||
|
||||
// Mock the initializeExtensions method to throw an error
|
||||
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
|
||||
ExtensionsManager.prototype.initializeExtensions = jest
|
||||
.fn()
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Test initialization error');
|
||||
});
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify error was logged
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error setting up extensions:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
// Restore original method
|
||||
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('logs success message when ExtensionsManager initializes successfully', async () => {
|
||||
const infoSpy = jest.spyOn(logging, 'info').mockImplementation();
|
||||
|
||||
// Mock the initializeExtensions method to succeed
|
||||
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
|
||||
ExtensionsManager.prototype.initializeExtensions = jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify success message was logged
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'Extensions initialized successfully.',
|
||||
);
|
||||
});
|
||||
|
||||
// Restore original method
|
||||
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
|
||||
infoSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('only initializes once even with multiple renders', async () => {
|
||||
// Track calls to the manager's public API
|
||||
const manager = ExtensionsManager.getInstance();
|
||||
@@ -203,3 +189,106 @@ test('only initializes once even with multiple renders', async () => {
|
||||
// Restore original method
|
||||
manager.initializeExtensions = originalInitialize;
|
||||
});
|
||||
|
||||
test('initializes ExtensionsManager and logs success when EnableExtensions feature flag is enabled', async () => {
|
||||
// Ensure feature flag is enabled
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
(flag: FeatureFlag) => flag === FeatureFlag.EnableExtensions,
|
||||
);
|
||||
|
||||
const infoSpy = jest.spyOn(logging, 'info').mockImplementation();
|
||||
|
||||
// Mock the initializeExtensions method to succeed
|
||||
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
|
||||
ExtensionsManager.prototype.initializeExtensions = jest
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify feature flag was checked
|
||||
expect(mockIsFeatureEnabled).toHaveBeenCalledWith(
|
||||
FeatureFlag.EnableExtensions,
|
||||
);
|
||||
// Verify initialization was called
|
||||
expect(
|
||||
ExtensionsManager.prototype.initializeExtensions,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
// Verify success message was logged
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'Extensions initialized successfully.',
|
||||
);
|
||||
});
|
||||
|
||||
// Restore original method
|
||||
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
|
||||
infoSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not initialize ExtensionsManager when EnableExtensions feature flag is disabled', async () => {
|
||||
// Disable the feature flag
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
|
||||
const manager = ExtensionsManager.getInstance();
|
||||
const initializeSpy = jest
|
||||
.spyOn(manager, 'initializeExtensions')
|
||||
.mockImplementation();
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify feature flag was checked
|
||||
expect(mockIsFeatureEnabled).toHaveBeenCalledWith(
|
||||
FeatureFlag.EnableExtensions,
|
||||
);
|
||||
// Verify the global superset object is still set up
|
||||
expect((window as any).superset).toBeDefined();
|
||||
// But extensions should not be initialized
|
||||
expect(initializeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
initializeSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('logs error when ExtensionsManager initialization fails', async () => {
|
||||
// Ensure feature flag is enabled
|
||||
mockIsFeatureEnabled.mockReturnValue(true);
|
||||
|
||||
const errorSpy = jest.spyOn(logging, 'error').mockImplementation();
|
||||
|
||||
// Mock the initializeExtensions method to throw an error
|
||||
const originalInitialize = ExtensionsManager.prototype.initializeExtensions;
|
||||
ExtensionsManager.prototype.initializeExtensions = jest
|
||||
.fn()
|
||||
.mockImplementation(() => {
|
||||
throw new Error('Test initialization error');
|
||||
});
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify feature flag was checked
|
||||
expect(mockIsFeatureEnabled).toHaveBeenCalledWith(
|
||||
FeatureFlag.EnableExtensions,
|
||||
);
|
||||
// Verify error was logged
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Error setting up extensions:',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
// Restore original method
|
||||
ExtensionsManager.prototype.initializeExtensions = originalInitialize;
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { logging } from '@superset-ui/core';
|
||||
import { FeatureFlag, isFeatureEnabled, logging } from '@superset-ui/core';
|
||||
import {
|
||||
authentication,
|
||||
core,
|
||||
@@ -75,14 +75,15 @@ const ExtensionsStartup = () => {
|
||||
};
|
||||
|
||||
// Initialize extensions
|
||||
try {
|
||||
ExtensionsManager.getInstance().initializeExtensions();
|
||||
logging.info('Extensions initialized successfully.');
|
||||
} catch (error) {
|
||||
logging.error('Error setting up extensions:', error);
|
||||
} finally {
|
||||
setInitialized(true);
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
try {
|
||||
ExtensionsManager.getInstance().initializeExtensions();
|
||||
logging.info('Extensions initialized successfully.');
|
||||
} catch (error) {
|
||||
logging.error('Error setting up extensions:', error);
|
||||
}
|
||||
}
|
||||
setInitialized(true);
|
||||
}, [initialized, userId]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -104,22 +104,14 @@ export const useThemeMenuItems = ({
|
||||
: []),
|
||||
];
|
||||
|
||||
const children: MenuItem[] = [
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: t('Theme'),
|
||||
key: 'theme-group',
|
||||
children: themeOptions,
|
||||
},
|
||||
];
|
||||
|
||||
// Add clear settings option only when there's a local theme active
|
||||
// Add clear settings option to theme options if there's a local theme active
|
||||
const themeGroupOptions = [...themeOptions];
|
||||
if (onClearLocalSettings && hasLocalOverride) {
|
||||
children.push({
|
||||
themeGroupOptions.push({
|
||||
type: 'divider' as const,
|
||||
key: 'theme-divider',
|
||||
});
|
||||
children.push({
|
||||
themeGroupOptions.push({
|
||||
key: 'clear-local',
|
||||
label: (
|
||||
<>
|
||||
@@ -130,6 +122,15 @@ export const useThemeMenuItems = ({
|
||||
});
|
||||
}
|
||||
|
||||
const children: MenuItem[] = [
|
||||
{
|
||||
type: 'group' as const,
|
||||
label: t('Theme'),
|
||||
key: 'theme-group',
|
||||
children: themeGroupOptions,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
key: 'theme-sub-menu',
|
||||
label: selectedThemeModeIcon,
|
||||
|
||||
@@ -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
+186
-186
@@ -25,22 +25,22 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.7.1",
|
||||
"@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.42.0",
|
||||
"eslint": "^9.37.0",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^16.4.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.6.2",
|
||||
"ts-jest": "^29.4.4",
|
||||
"ts-jest": "^29.4.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.0"
|
||||
"typescript-eslint": "^8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4",
|
||||
@@ -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.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
||||
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
|
||||
"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": {
|
||||
@@ -1909,17 +1911,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
|
||||
"integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
|
||||
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/type-utils": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/type-utils": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -1933,7 +1935,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.46.0",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
@@ -1949,16 +1951,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
|
||||
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
|
||||
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1974,14 +1976,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
|
||||
"integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
|
||||
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.0",
|
||||
"@typescript-eslint/types": "^8.46.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.1",
|
||||
"@typescript-eslint/types": "^8.46.1",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1996,14 +1998,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
|
||||
"integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
|
||||
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0"
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2014,9 +2016,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
|
||||
"integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
|
||||
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2031,15 +2033,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
|
||||
"integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
|
||||
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
@@ -2056,9 +2058,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
|
||||
"integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
|
||||
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2070,16 +2072,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
|
||||
"integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
|
||||
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.46.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/project-service": "8.46.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -2125,16 +2127,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
|
||||
"integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
|
||||
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0"
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2149,13 +2151,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
|
||||
"integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
|
||||
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -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",
|
||||
@@ -5849,9 +5850,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -6122,9 +6123,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ts-jest": {
|
||||
"version": "29.4.4",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz",
|
||||
"integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==",
|
||||
"version": "29.4.5",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz",
|
||||
"integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6134,7 +6135,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"make-error": "^1.3.6",
|
||||
"semver": "^7.7.2",
|
||||
"semver": "^7.7.3",
|
||||
"type-fest": "^4.41.0",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
@@ -6305,16 +6306,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
|
||||
"integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
|
||||
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.46.0",
|
||||
"@typescript-eslint/parser": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -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.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
||||
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
|
||||
"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"
|
||||
@@ -8106,16 +8107,16 @@
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
|
||||
"integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
|
||||
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/type-utils": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/type-utils": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -8131,75 +8132,75 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/parser": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
|
||||
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
|
||||
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"debug": "^4.3.4"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/project-service": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
|
||||
"integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
|
||||
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.0",
|
||||
"@typescript-eslint/types": "^8.46.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.1",
|
||||
"@typescript-eslint/types": "^8.46.1",
|
||||
"debug": "^4.3.4"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/scope-manager": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
|
||||
"integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
|
||||
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0"
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
|
||||
"integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
|
||||
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@typescript-eslint/type-utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
|
||||
"integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
|
||||
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
|
||||
"integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
|
||||
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
|
||||
"integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
|
||||
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/project-service": "8.46.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/project-service": "8.46.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -8229,24 +8230,24 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
|
||||
"integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
|
||||
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0"
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
|
||||
"integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
|
||||
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -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",
|
||||
@@ -10970,9 +10970,9 @@
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="
|
||||
},
|
||||
"shebang-command": {
|
||||
"version": "2.0.0",
|
||||
@@ -11169,9 +11169,9 @@
|
||||
"requires": {}
|
||||
},
|
||||
"ts-jest": {
|
||||
"version": "29.4.4",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz",
|
||||
"integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==",
|
||||
"version": "29.4.5",
|
||||
"resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz",
|
||||
"integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"bs-logger": "^0.2.6",
|
||||
@@ -11180,7 +11180,7 @@
|
||||
"json5": "^2.2.3",
|
||||
"lodash.memoize": "^4.1.2",
|
||||
"make-error": "^1.3.6",
|
||||
"semver": "^7.7.2",
|
||||
"semver": "^7.7.3",
|
||||
"type-fest": "^4.41.0",
|
||||
"yargs-parser": "^21.1.1"
|
||||
}
|
||||
@@ -11259,15 +11259,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"typescript-eslint": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
|
||||
"integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
|
||||
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/eslint-plugin": "8.46.0",
|
||||
"@typescript-eslint/parser": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1"
|
||||
}
|
||||
},
|
||||
"uglify-js": {
|
||||
|
||||
@@ -33,22 +33,22 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/node": "^24.7.1",
|
||||
"@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.42.0",
|
||||
"eslint": "^9.37.0",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"globals": "^16.4.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.6.2",
|
||||
"ts-jest": "^29.4.4",
|
||||
"ts-jest": "^29.4.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.0"
|
||||
"typescript-eslint": "^8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.4",
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -972,7 +972,7 @@ CORS_OPTIONS: dict[Any, Any] = {
|
||||
# Disabling this option is not recommended for security reasons. If you wish to allow
|
||||
# valid safe elements that are not included in the default sanitization schema, use the
|
||||
# HTML_SANITIZATION_SCHEMA_EXTENSIONS configuration.
|
||||
HTML_SANITIZATION = False
|
||||
HTML_SANITIZATION = True
|
||||
|
||||
# Use this configuration to extend the HTML sanitization schema.
|
||||
# By default we use the GitHub schema defined in
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -22,6 +22,7 @@ from typing import Any
|
||||
|
||||
from celery import Task
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from celery.signals import task_failure
|
||||
from flask import current_app
|
||||
|
||||
from superset import is_feature_enabled
|
||||
@@ -41,8 +42,32 @@ from superset.utils.log import get_logger_from_status
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="reports.scheduler")
|
||||
def scheduler() -> None:
|
||||
@task_failure.connect
|
||||
def log_task_failure( # pylint: disable=unused-argument
|
||||
sender: Task | None = None,
|
||||
task_id: str | None = None,
|
||||
exception: Exception | None = None,
|
||||
args: tuple[Any, ...] | None = None,
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
traceback: Any = None,
|
||||
einfo: Any = None,
|
||||
**kw: Any,
|
||||
) -> None:
|
||||
task_name = sender.name if sender else "Unknown"
|
||||
logger.exception("Celery task %s failed: %s", task_name, exception, exc_info=einfo)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="reports.scheduler",
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_kwargs={
|
||||
"max_retries": 3,
|
||||
"countdown": 60,
|
||||
}, # Retry up to 3 times, wait 60s between
|
||||
retry_backoff=True, # exponential backoff
|
||||
)
|
||||
def scheduler(self: Task) -> None: # pylint: disable=unused-argument
|
||||
"""
|
||||
Celery beat main scheduler for reports
|
||||
"""
|
||||
|
||||
@@ -550,15 +550,9 @@ class ThemeRestApi(BaseSupersetModelRestApi):
|
||||
|
||||
overwrite = request.form.get("overwrite") == "true"
|
||||
|
||||
try:
|
||||
ImportThemesCommand(contents, overwrite=overwrite).run()
|
||||
return self.response(200, message="Theme imported successfully")
|
||||
except ValidationError as err:
|
||||
logger.exception("Import themes validation error")
|
||||
return self.response_400(message=str(err))
|
||||
except Exception as ex:
|
||||
logger.exception("Unexpected error importing themes")
|
||||
return self.response_422(message=str(ex))
|
||||
command = ImportThemesCommand(contents, overwrite=overwrite)
|
||||
command.run()
|
||||
return self.response(200, message="Theme imported successfully")
|
||||
|
||||
@expose("/<int:pk>/set_system_default", methods=("PUT",))
|
||||
@protect()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -19,9 +19,14 @@
|
||||
|
||||
import pytest
|
||||
import prison
|
||||
import uuid
|
||||
import yaml
|
||||
from datetime import datetime
|
||||
from freezegun import freeze_time
|
||||
from io import BytesIO
|
||||
from sqlalchemy.sql import func
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import tests.integration_tests.test_app # noqa: F401
|
||||
from superset import db
|
||||
@@ -399,3 +404,120 @@ class TestThemeApi(SupersetTestCase):
|
||||
uri = f"api/v1/theme/?q={prison.dumps(theme_ids)}"
|
||||
rv = self.delete_assert_metric(uri, "bulk_delete")
|
||||
assert rv.status_code == 404
|
||||
|
||||
def create_theme_import_zip(self, theme_config: dict[str, Any]) -> BytesIO:
|
||||
"""Helper method to create a theme import ZIP file"""
|
||||
buf = BytesIO()
|
||||
with ZipFile(buf, "w") as bundle:
|
||||
# Use a root folder like the export does
|
||||
root = "theme_import"
|
||||
|
||||
# Add metadata.yaml
|
||||
metadata = {
|
||||
"version": "1.0.0",
|
||||
"type": "Theme",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
with bundle.open(f"{root}/metadata.yaml", "w") as fp:
|
||||
fp.write(yaml.safe_dump(metadata).encode())
|
||||
|
||||
# Add theme YAML file
|
||||
theme_yaml = yaml.safe_dump(theme_config)
|
||||
with bundle.open(
|
||||
f"{root}/themes/{theme_config['theme_name']}.yaml", "w"
|
||||
) as fp:
|
||||
fp.write(theme_yaml.encode())
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
def test_import_theme(self):
|
||||
"""
|
||||
Theme API: Test import theme
|
||||
"""
|
||||
theme_config = {
|
||||
"theme_name": "imported_theme",
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"version": "1.0.0",
|
||||
"json_data": {"colors": {"primary": "#007bff"}},
|
||||
}
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = "api/v1/theme/import/"
|
||||
|
||||
buf = self.create_theme_import_zip(theme_config)
|
||||
form_data = {
|
||||
"formData": (buf, "theme_export.zip"),
|
||||
}
|
||||
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
assert rv.status_code == 200
|
||||
assert response == {"message": "Theme imported successfully"}
|
||||
|
||||
theme = db.session.query(Theme).filter_by(uuid=theme_config["uuid"]).one()
|
||||
assert theme.theme_name == "imported_theme"
|
||||
|
||||
# Cleanup
|
||||
db.session.delete(theme)
|
||||
db.session.commit()
|
||||
|
||||
def test_import_theme_overwrite(self):
|
||||
"""
|
||||
Theme API: Test import existing theme without and with overwrite
|
||||
"""
|
||||
theme_config = {
|
||||
"theme_name": "overwrite_theme",
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"version": "1.0.0",
|
||||
"json_data": {"colors": {"primary": "#007bff"}},
|
||||
}
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = "api/v1/theme/import/"
|
||||
|
||||
# First import
|
||||
buf = self.create_theme_import_zip(theme_config)
|
||||
form_data = {
|
||||
"formData": (buf, "theme_export.zip"),
|
||||
}
|
||||
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
assert rv.status_code == 200
|
||||
assert response == {"message": "Theme imported successfully"}
|
||||
|
||||
# Import again without overwrite flag - should fail with structured error
|
||||
buf = self.create_theme_import_zip(theme_config)
|
||||
form_data = {
|
||||
"formData": (buf, "theme_export.zip"),
|
||||
}
|
||||
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
assert rv.status_code == 422
|
||||
assert len(response["errors"]) == 1
|
||||
error = response["errors"][0]
|
||||
assert error["message"].startswith("Error importing theme")
|
||||
assert error["error_type"] == "GENERIC_COMMAND_ERROR"
|
||||
assert error["level"] == "warning"
|
||||
assert f"themes/{theme_config['theme_name']}.yaml" in str(error["extra"])
|
||||
assert "Theme already exists and `overwrite=true` was not passed" in str(
|
||||
error["extra"]
|
||||
)
|
||||
|
||||
# Import with overwrite flag - should succeed
|
||||
buf = self.create_theme_import_zip(theme_config)
|
||||
form_data = {
|
||||
"formData": (buf, "theme_export.zip"),
|
||||
"overwrite": "true",
|
||||
}
|
||||
rv = self.client.post(uri, data=form_data, content_type="multipart/form-data")
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
assert rv.status_code == 200
|
||||
assert response == {"message": "Theme imported successfully"}
|
||||
|
||||
# Cleanup
|
||||
theme = db.session.query(Theme).filter_by(uuid=theme_config["uuid"]).one()
|
||||
db.session.delete(theme)
|
||||
db.session.commit()
|
||||
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user