mirror of
https://github.com/apache/superset.git
synced 2026-05-14 04:15:12 +00:00
Compare commits
14 Commits
embedded-e
...
feat/sqlla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85920f701a | ||
|
|
f08bcd4d09 | ||
|
|
53fcc21992 | ||
|
|
93e31723cb | ||
|
|
ebc5122af8 | ||
|
|
fbdede3961 | ||
|
|
60fc4d459b | ||
|
|
842f749c9f | ||
|
|
24f994b5c2 | ||
|
|
66e8094823 | ||
|
|
243c66be1f | ||
|
|
c5532c8229 | ||
|
|
853a6b10e7 | ||
|
|
74a6dba3ab |
@@ -164,8 +164,13 @@ Extensions configure Webpack to expose their entry points:
|
||||
|
||||
```javascript
|
||||
externalsType: 'window',
|
||||
externals: {
|
||||
'@apache-superset/core': 'superset',
|
||||
externals: ({ request }, callback) => {
|
||||
// Map @apache-superset/core and subpaths to window.superset
|
||||
if (request?.startsWith('@apache-superset/core')) {
|
||||
const parts = request.replace('@apache-superset/core', 'superset').split('/');
|
||||
return callback(null, parts);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
@@ -187,7 +192,7 @@ This configuration does several important things:
|
||||
|
||||
**`exposes`** - Declares which modules are available to the host application. Superset always loads extensions by requesting the `./index` module from the remote container — this is a fixed convention, not a configurable value. Extensions must expose exactly `'./index': './src/index.tsx'` and place all API registrations (views, commands, menus, editors, event listeners) in that file. The module is executed as a side effect when the extension loads, so any call to `views.registerView`, `commands.registerCommand`, etc. made at the top level of `index.tsx` will run automatically.
|
||||
|
||||
**`externals` and `externalsType`** - Tell Webpack that when the extension imports `@apache-superset/core`, it should use `window.superset` at runtime instead of bundling its own copy. This ensures extensions use the host's implementation of shared packages.
|
||||
**`externals` and `externalsType`** - Tell Webpack that when the extension imports from `@apache-superset/core` or its subpaths (like `@apache-superset/core/storage`), it should resolve to `window.superset` or `window.superset.storage` at runtime. The function-based externals returns an array of path segments, which Webpack uses for nested property access.
|
||||
|
||||
**`shared`** - Prevents duplication of common libraries like React and Ant Design. The `singleton: true` setting ensures only one instance of each library exists, avoiding version conflicts and reducing bundle size.
|
||||
|
||||
|
||||
@@ -55,5 +55,6 @@ Extension developers have access to pre-built UI components via `@apache-superse
|
||||
- **[Deployment](./deployment)** - Packaging and deploying extensions
|
||||
- **[MCP Integration](./mcp)** - Adding AI agent capabilities using extensions
|
||||
- **[Security](./security)** - Security considerations and best practices
|
||||
- **[Storage](./storage)** - Managed storage API for persisting extension data
|
||||
- **[Tasks](./tasks)** - Framework for creating and managing long running tasks
|
||||
- **[Community Extensions](./registry)** - Browse extensions shared by the community
|
||||
|
||||
@@ -223,7 +223,7 @@ The `@apache-superset/core` package must be listed in both `peerDependencies` (t
|
||||
|
||||
**`frontend/webpack.config.js`**
|
||||
|
||||
The webpack configuration requires specific settings for Module Federation. Key settings include `externalsType: "window"` and `externals` to map `@apache-superset/core` to `window.superset` at runtime, `import: false` for shared modules to use the host's React instead of bundling a separate copy, and `remoteEntry.[contenthash].js` for cache busting.
|
||||
The webpack configuration requires specific settings for Module Federation. Key settings include `externalsType: "window"` and a function-based `externals` to map `@apache-superset/core` and its subpaths (like `@apache-superset/core/storage`) to `window.superset` at runtime, `import: false` for shared modules to use the host's React instead of bundling a separate copy, and `remoteEntry.[contenthash].js` for cache busting.
|
||||
|
||||
**Convention**: Superset always loads extensions by requesting the `./index` module from the Module Federation container. The `exposes` entry must be exactly `'./index': './src/index.tsx'` — do not rename or add additional entries. All API registrations must be reachable from that file. See [Architecture](./architecture#module-federation) for a full explanation.
|
||||
|
||||
@@ -255,10 +255,14 @@ module.exports = (env, argv) => {
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||
},
|
||||
// Map @apache-superset/core imports to window.superset at runtime
|
||||
// Map @apache-superset/core and subpaths to window.superset at runtime
|
||||
externalsType: 'window',
|
||||
externals: {
|
||||
'@apache-superset/core': 'superset',
|
||||
externals: ({ request }, callback) => {
|
||||
if (request?.startsWith('@apache-superset/core')) {
|
||||
const parts = request.replace('@apache-superset/core', 'superset').split('/');
|
||||
return callback(null, parts);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
||||
316
docs/developer_docs/extensions/storage.md
Normal file
316
docs/developer_docs/extensions/storage.md
Normal file
@@ -0,0 +1,316 @@
|
||||
---
|
||||
title: Storage
|
||||
sidebar_position: 8
|
||||
---
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# Storage
|
||||
|
||||
Superset Extensions have access to a managed storage API for persisting data. The storage system provides multiple tiers with different persistence characteristics, allowing extensions to choose the right storage for their needs.
|
||||
|
||||
Each extension receives its own isolated storage namespace. When Superset loads your extension, it binds storage to your extension's unique identifier, ensuring data privacy—two extensions using the same key will never collide, and extensions cannot access each other's data.
|
||||
|
||||
## Storage Tiers
|
||||
|
||||
| Tier | Storage Type | Context Property | Use Case |
|
||||
| ---- | ----------------- | ------------------------------------------ | -------------------------------------- |
|
||||
| 1 | Browser storage | `ctx.storage.local`, `ctx.storage.session` | UI state, wizard progress, draft forms |
|
||||
| 2 | Server-side cache | `ctx.storage.ephemeral` | Job progress, temporary results |
|
||||
| 3 | Database | `ctx.storage.persistent` | User preferences, durable config |
|
||||
|
||||
## Tier 1: Local State
|
||||
|
||||
Browser-based storage that persists on the user's device. Use this for UI state and settings that don't need to sync across devices.
|
||||
|
||||
### Why Use the API Instead of localStorage Directly?
|
||||
|
||||
You might wonder why extensions should use `ctx.storage.local` instead of directly accessing `window.localStorage`. The managed API provides several benefits:
|
||||
|
||||
- **Automatic namespacing**: Each extension's data is isolated. Two extensions using the same key name won't collide.
|
||||
- **User isolation**: By default, data is scoped to the current user, preventing data leakage between users on shared devices.
|
||||
- **Clean uninstall**: When an extension is uninstalled, all its data can be cleanly removed using prefix-based deletion.
|
||||
- **Future sandboxing**: The async API is designed for a future sandboxed execution model where extensions run in isolated contexts without direct DOM access.
|
||||
- **Consistent patterns**: The same API shape works across all storage tiers, making it easy to switch between them.
|
||||
|
||||
### localState
|
||||
|
||||
Data persists across browser sessions until explicitly deleted or the user clears browser storage.
|
||||
|
||||
```typescript
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Save sidebar state
|
||||
await ctx.storage.local.set('sidebar_collapsed', true);
|
||||
|
||||
// Retrieve it later
|
||||
const isCollapsed = await ctx.storage.local.get('sidebar_collapsed');
|
||||
|
||||
// Remove it
|
||||
await ctx.storage.local.remove('sidebar_collapsed');
|
||||
```
|
||||
|
||||
### sessionState
|
||||
|
||||
Data is cleared when the browser tab is closed. Use for transient state within a single session.
|
||||
|
||||
```typescript
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Save wizard progress (lost when tab closes)
|
||||
await ctx.storage.session.set('wizard_step', 3);
|
||||
await ctx.storage.session.set('unsaved_form', { name: 'Draft' });
|
||||
|
||||
// Retrieve on page reload within same tab
|
||||
const step = await ctx.storage.session.get('wizard_step');
|
||||
```
|
||||
|
||||
### Shared State
|
||||
|
||||
By default, data is scoped to the current user. Use `shared` for data that should be accessible to all users on the same device.
|
||||
|
||||
```typescript
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Shared across all users on this device
|
||||
await ctx.storage.local.shared.set('device_id', 'abc-123');
|
||||
const deviceId = await ctx.storage.local.shared.get('device_id');
|
||||
```
|
||||
|
||||
### When to Use Tier 1
|
||||
|
||||
- UI state (sidebar collapsed, panel sizes)
|
||||
- Recently used items
|
||||
- Draft form values
|
||||
- Any data acceptable to lose if user clears browser
|
||||
|
||||
### Limitations
|
||||
|
||||
- Per-browser, per-device (not shared across devices)
|
||||
- Subject to browser storage quotas (~5-10 MB)
|
||||
- Not accessible from backend code
|
||||
|
||||
## Tier 2: Ephemeral State
|
||||
|
||||
Server-side cache storage with automatic TTL expiration. Use for temporary data that needs to be shared between frontend and backend, or persist across page reloads.
|
||||
|
||||
### Frontend Usage
|
||||
|
||||
```typescript
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Store with server default TTL (CACHE_DEFAULT_TIMEOUT)
|
||||
await ctx.storage.ephemeral.set('job_progress', { pct: 42, status: 'running' });
|
||||
|
||||
// Store with custom TTL (5 minutes)
|
||||
await ctx.storage.ephemeral.set(
|
||||
'quick_cache',
|
||||
{ results: [1, 2, 3] },
|
||||
{ ttl: 300 },
|
||||
);
|
||||
|
||||
// Retrieve
|
||||
const progress = await ctx.storage.ephemeral.get('job_progress');
|
||||
|
||||
// Remove
|
||||
await ctx.storage.ephemeral.remove('job_progress');
|
||||
```
|
||||
|
||||
### Backend Usage
|
||||
|
||||
```python
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# Store job progress (uses CACHE_DEFAULT_TIMEOUT when ttl is omitted)
|
||||
ctx.storage.ephemeral.set('job_progress', {'pct': 42, 'status': 'running'})
|
||||
|
||||
# Retrieve
|
||||
progress = ctx.storage.ephemeral.get('job_progress')
|
||||
|
||||
# Remove
|
||||
ctx.storage.ephemeral.remove('job_progress')
|
||||
```
|
||||
|
||||
### Shared State
|
||||
|
||||
For data that needs to be visible to all users:
|
||||
|
||||
```typescript
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
await ctx.storage.ephemeral.shared.set('shared_result', { data: [1, 2, 3] });
|
||||
const result = await ctx.storage.ephemeral.shared.get('shared_result');
|
||||
```
|
||||
|
||||
```python
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
ctx.storage.ephemeral.shared.set('shared_result', {'data': [1, 2, 3]})
|
||||
result = ctx.storage.ephemeral.shared.get('shared_result')
|
||||
```
|
||||
|
||||
### When to Use Tier 2
|
||||
|
||||
- Background job progress indicators
|
||||
- Cross-request intermediate state
|
||||
- Query result previews
|
||||
- Temporary computation results
|
||||
- Any data that can be recomputed if lost
|
||||
|
||||
### Limitations
|
||||
|
||||
- Not guaranteed to survive server restarts
|
||||
- Subject to cache eviction under memory pressure
|
||||
- TTL-based expiration (data disappears after timeout)
|
||||
|
||||
## Tier 3: Persistent State
|
||||
|
||||
Database-backed storage that survives server restarts, cache evictions, and browser clears. Use for any data that must not be lost.
|
||||
|
||||
### Frontend Usage
|
||||
|
||||
```typescript
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
// Store user preferences
|
||||
await ctx.storage.persistent.set('preferences', { theme: 'dark', locale: 'en' });
|
||||
|
||||
// Retrieve
|
||||
const prefs = await ctx.storage.persistent.get('preferences');
|
||||
|
||||
// Remove
|
||||
await ctx.storage.persistent.remove('preferences');
|
||||
```
|
||||
|
||||
### Backend Usage
|
||||
|
||||
```python
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# Store user preferences
|
||||
ctx.storage.persistent.set('preferences', {'theme': 'dark', 'locale': 'en'})
|
||||
|
||||
# Retrieve
|
||||
prefs = ctx.storage.persistent.get('preferences')
|
||||
|
||||
# Remove
|
||||
ctx.storage.persistent.remove('preferences')
|
||||
```
|
||||
|
||||
### Shared State
|
||||
|
||||
For data that should be visible to all users of the extension:
|
||||
|
||||
```typescript
|
||||
import { getContext } from '@apache-superset/core/extensions';
|
||||
|
||||
const ctx = getContext();
|
||||
|
||||
await ctx.storage.persistent.shared.set('global_config', { version: 2 });
|
||||
const config = await ctx.storage.persistent.shared.get('global_config');
|
||||
```
|
||||
|
||||
```python
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
ctx.storage.persistent.shared.set('global_config', {'version': 2})
|
||||
config = ctx.storage.persistent.shared.get('global_config')
|
||||
```
|
||||
|
||||
### When to Use Tier 3
|
||||
|
||||
- User preferences and settings
|
||||
- Extension configuration that must survive restarts
|
||||
- Saved state that needs to roam across devices and browsers
|
||||
- Any data where loss is unacceptable
|
||||
|
||||
### Limitations
|
||||
|
||||
- Higher latency than Tiers 1–2 (database round-trip per operation)
|
||||
- Subject to the 16 MB value size limit
|
||||
- Requires a database migration when first deployed
|
||||
|
||||
## Key Patterns
|
||||
|
||||
All storage keys are automatically namespaced:
|
||||
|
||||
| Scope | Key Pattern |
|
||||
| ----------- | -------------------------------------------------- |
|
||||
| User-scoped | `superset-ext:{extension_id}:user:{user_id}:{key}` |
|
||||
| Shared | `superset-ext:{extension_id}:shared:{key}` |
|
||||
|
||||
This ensures:
|
||||
|
||||
- Extensions cannot accidentally access each other's data
|
||||
- Users cannot see other users' data (by default)
|
||||
- Clean prefix-based deletion on uninstall
|
||||
|
||||
## Configuration
|
||||
|
||||
### Tier 2: Ephemeral Storage
|
||||
|
||||
Administrators can configure the server-side cache backend in `superset_config.py`:
|
||||
|
||||
```python
|
||||
EXTENSIONS_STORAGE = {
|
||||
"EPHEMERAL": {
|
||||
# Use Redis for better performance in production
|
||||
"CACHE_TYPE": "RedisCache",
|
||||
"CACHE_REDIS_URL": "redis://localhost:6379/2",
|
||||
"CACHE_DEFAULT_TIMEOUT": 3600, # 1 hour default TTL
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For development, the default `SupersetMetastoreCache` stores data in the metadata database.
|
||||
|
||||
### Tier 3: Persistent Storage
|
||||
|
||||
Tier 3 values are stored in the `extension_storage` database table. The encryption infrastructure is in place (Fernet-based, keyed from `EXTENSION_STORAGE_ENCRYPTION_KEYS`), but values written through the standard storage API are stored unencrypted by default. Encryption is available at the DAO layer for backend extensions that call `ExtensionStorageDAO.set(..., is_encrypted=True)` directly.
|
||||
|
||||
```python
|
||||
# Optional: override the encryption key(s) used for Tier 3 persistent storage.
|
||||
# Falls back to SECRET_KEY when not set.
|
||||
# Rotate keys by prepending the new key — all keys are tried on decryption.
|
||||
EXTENSION_STORAGE_ENCRYPTION_KEYS = [
|
||||
"my-new-key-base64url-encoded", # used for new writes
|
||||
"my-old-key-base64url-encoded", # kept for reading old values
|
||||
]
|
||||
```
|
||||
@@ -88,6 +88,7 @@ const sidebars = {
|
||||
'extensions/deployment',
|
||||
'extensions/mcp',
|
||||
'extensions/security',
|
||||
'extensions/storage',
|
||||
'extensions/tasks',
|
||||
'extensions/registry',
|
||||
],
|
||||
|
||||
137
superset-core/src/superset_core/extensions/context.py
Normal file
137
superset-core/src/superset_core/extensions/context.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Extension Context API for superset-core extensions.
|
||||
|
||||
Provides access to the current extension's context, including metadata
|
||||
and scoped resources like storage. Extensions call `get_context()` to
|
||||
access their context during execution.
|
||||
|
||||
The context is set by the host (Superset) during extension loading and
|
||||
is only available within extension code.
|
||||
|
||||
Usage:
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
def setup():
|
||||
ctx = get_context()
|
||||
|
||||
# Access extension metadata
|
||||
print(f"Running {ctx.extension.displayName} v{ctx.extension.version}")
|
||||
|
||||
# Access extension-scoped storage
|
||||
ctx.storage.ephemeral.set("lastRun", time.time())
|
||||
data = ctx.storage.ephemeral.get("cachedData")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
|
||||
class StorageAccessor(Protocol):
|
||||
"""Protocol for storage access with user-scoped and shared modes."""
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Get a value from storage."""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int = 3600) -> None:
|
||||
"""Set a value in storage with optional TTL."""
|
||||
...
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
"""Remove a value from storage."""
|
||||
...
|
||||
|
||||
@property
|
||||
def shared(self) -> "StorageAccessor":
|
||||
"""Shared (cross-user) storage accessor."""
|
||||
...
|
||||
|
||||
|
||||
class ExtensionStorage(Protocol):
|
||||
"""Extension-scoped storage accessor for all available tiers."""
|
||||
|
||||
@property
|
||||
def ephemeral(self) -> StorageAccessor:
|
||||
"""Server-side cache (Redis/Memcached) with TTL."""
|
||||
...
|
||||
|
||||
# Future tiers:
|
||||
# @property
|
||||
# def persistent(self) -> StorageAccessor:
|
||||
# """Database-backed persistent storage."""
|
||||
# ...
|
||||
|
||||
|
||||
class ExtensionContext(Protocol):
|
||||
"""
|
||||
Context object providing extension-specific resources.
|
||||
|
||||
This context is only available during extension execution.
|
||||
Calling `get_context()` outside of an extension will raise an error.
|
||||
"""
|
||||
|
||||
@property
|
||||
def extension(self) -> "Manifest":
|
||||
"""Metadata about the current extension."""
|
||||
...
|
||||
|
||||
@property
|
||||
def storage(self) -> ExtensionStorage:
|
||||
"""Extension-scoped storage across all available tiers."""
|
||||
...
|
||||
|
||||
|
||||
def get_context() -> ExtensionContext:
|
||||
"""
|
||||
Get the current extension's context.
|
||||
|
||||
This function returns the context for the currently executing extension,
|
||||
providing access to extension metadata and scoped resources like storage.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:returns: The current extension's context.
|
||||
:raises RuntimeError: If called outside of an extension context.
|
||||
|
||||
Example:
|
||||
from superset_core.extensions.context import get_context
|
||||
|
||||
ctx = get_context()
|
||||
|
||||
# Access extension metadata
|
||||
print(f"Extension: {ctx.extension.id}")
|
||||
print(f"Version: {ctx.extension.version}")
|
||||
|
||||
# Access extension-scoped storage
|
||||
ctx.storage.ephemeral.set("tempData", data, ttl=3600)
|
||||
value = ctx.storage.ephemeral.get("tempData")
|
||||
|
||||
# Access shared (cross-user) storage
|
||||
ctx.storage.ephemeral.shared.set("globalCounter", count)
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"get_context() must be called within an extension context. "
|
||||
"This function is replaced by the host during extension loading."
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Storage API for superset-core extensions.
|
||||
|
||||
Provides storage tiers for extensions with different persistence characteristics:
|
||||
|
||||
Tier 1 - Local State (Frontend Only):
|
||||
- localState: Browser localStorage - persists across sessions
|
||||
- sessionState: Browser sessionStorage - cleared on tab close
|
||||
These are frontend-only and cannot be imported in backend code.
|
||||
|
||||
Tier 2 - Ephemeral State (Server Cache):
|
||||
- ephemeral_state: Short-lived KV storage backed by server-side cache
|
||||
- Supports TTL, not guaranteed to survive server restarts
|
||||
- Use for temporary state like job progress or intermediate results
|
||||
|
||||
Tier 3 - Persistent State (Database) [Future]:
|
||||
- persistent_state: Durable KV storage backed by database table
|
||||
- Survives server restarts, supports encryption and resource linking
|
||||
- Use for user preferences, extension config, per-resource settings
|
||||
|
||||
All tiers follow the same API pattern:
|
||||
- User-scoped by default (private to current user)
|
||||
- `shared` accessor for data visible to all users
|
||||
|
||||
Usage:
|
||||
from superset_core.extensions.storage import ephemeral_state
|
||||
|
||||
# User-scoped state (default - private to current user)
|
||||
ephemeral_state.get('preference')
|
||||
ephemeral_state.set('preference', 'compact', ttl=3600)
|
||||
|
||||
# Shared state (explicit opt-in - visible to all users)
|
||||
ephemeral_state.shared.get('job_progress')
|
||||
ephemeral_state.shared.set('job_progress', {'pct': 42}, ttl=3600)
|
||||
|
||||
# Tier 3: Persistent state
|
||||
from superset_core.extensions.storage import persistent_state
|
||||
persistent_state.get('config')
|
||||
persistent_state.set('config', {'version': 2})
|
||||
"""
|
||||
|
||||
from superset_core.extensions.storage import (
|
||||
ephemeral_state, # noqa: F401
|
||||
persistent_state, # noqa: F401
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Ephemeral State API for superset-core extensions (Tier 2 Storage).
|
||||
|
||||
Provides short-lived KV storage backed by the configured server-side cache
|
||||
backend (Redis, Memcached, or filesystem). Automatically expires based on TTL.
|
||||
Not guaranteed to survive server restarts.
|
||||
|
||||
Host implementations will replace these functions during initialization
|
||||
with concrete implementations providing actual functionality.
|
||||
|
||||
Cache keys are namespaced automatically:
|
||||
- User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
|
||||
- Shared (global): superset-ext:{extension_id}:{key}
|
||||
|
||||
Usage:
|
||||
from superset_core.extensions.storage import ephemeral_state
|
||||
|
||||
# User-scoped state (default - private to current user)
|
||||
ephemeral_state.get('preference')
|
||||
ephemeral_state.set('preference', 'compact')
|
||||
ephemeral_state.remove('preference')
|
||||
|
||||
# Shared state (explicit opt-in - visible to all users)
|
||||
ephemeral_state.shared.get('job_progress')
|
||||
ephemeral_state.shared.set('job_progress', {'pct': 42})
|
||||
ephemeral_state.shared.remove('job_progress')
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class EphemeralStateAccessor(Protocol):
|
||||
"""Protocol for scoped ephemeral state access."""
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Get a value from ephemeral state."""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
||||
"""Set a value in ephemeral state with TTL."""
|
||||
...
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
"""Remove a value from ephemeral state."""
|
||||
...
|
||||
|
||||
|
||||
def get(key: str) -> Any:
|
||||
"""
|
||||
Get a value from user-scoped ephemeral state.
|
||||
|
||||
Data is automatically scoped to the current authenticated user.
|
||||
Other users cannot see or modify this data.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param key: The key to retrieve.
|
||||
:returns: The stored value, or None if not found or expired.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
def set(key: str, value: Any, ttl: int | None = None) -> None:
|
||||
"""
|
||||
Set a value in user-scoped ephemeral state with TTL.
|
||||
|
||||
Data is automatically scoped to the current authenticated user.
|
||||
Other users cannot see or modify this data.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param key: The key to store.
|
||||
:param value: The value to store (must be JSON-serializable).
|
||||
:param ttl: Time-to-live in seconds. Defaults to CACHE_DEFAULT_TIMEOUT.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
def remove(key: str) -> None:
|
||||
"""
|
||||
Remove a value from user-scoped ephemeral state.
|
||||
|
||||
Data is automatically scoped to the current authenticated user.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param key: The key to remove.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
class _SharedStub:
|
||||
"""Stub for shared accessor that raises NotImplementedError on any operation."""
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
|
||||
#: Shared (global) ephemeral state accessor.
|
||||
#: Data stored via this accessor is visible to all users of the extension.
|
||||
#: WARNING: Do not store user-specific or sensitive data here.
|
||||
#: Host implementations will replace this during initialization.
|
||||
shared: EphemeralStateAccessor = _SharedStub()
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Persistent State API for superset-core extensions (Tier 3 Storage).
|
||||
|
||||
Provides durable KV storage backed by a dedicated database table.
|
||||
Data survives server restarts, cache evictions, and browser clears.
|
||||
Suitable for user preferences, saved state, and any data that must not be lost.
|
||||
|
||||
Host implementations will replace these functions during initialization
|
||||
with concrete implementations providing actual functionality.
|
||||
|
||||
Database keys are namespaced automatically:
|
||||
- User-scoped (default): (extension_id, user_id, key)
|
||||
- Shared (global): (extension_id, null, key)
|
||||
|
||||
Usage:
|
||||
from superset_core.extensions.storage import persistent_state
|
||||
|
||||
# User-scoped state (default - private to current user)
|
||||
persistent_state.get('preferences')
|
||||
persistent_state.set('preferences', {'theme': 'dark'})
|
||||
persistent_state.remove('preferences')
|
||||
|
||||
# Shared state (explicit opt-in - visible to all users)
|
||||
persistent_state.shared.get('global_config')
|
||||
persistent_state.shared.set('global_config', {'version': 2})
|
||||
persistent_state.shared.remove('global_config')
|
||||
"""
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class PersistentStateAccessor(Protocol):
|
||||
"""Protocol for scoped persistent state access."""
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""Get a value from persistent state."""
|
||||
...
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a value in persistent state."""
|
||||
...
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
"""Remove a value from persistent state."""
|
||||
...
|
||||
|
||||
|
||||
def get(key: str) -> Any:
|
||||
"""
|
||||
Get a value from user-scoped persistent state.
|
||||
|
||||
Data is automatically scoped to the current authenticated user.
|
||||
Other users cannot see or modify this data.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param key: The key to retrieve.
|
||||
:returns: The stored value, or None if not found.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
def set(key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a value in user-scoped persistent state.
|
||||
|
||||
Data is automatically scoped to the current authenticated user.
|
||||
Other users cannot see or modify this data.
|
||||
Data persists indefinitely until explicitly removed.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param key: The key to store.
|
||||
:param value: The value to store (must be JSON-serializable).
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
def remove(key: str) -> None:
|
||||
"""
|
||||
Remove a value from user-scoped persistent state.
|
||||
|
||||
Data is automatically scoped to the current authenticated user.
|
||||
|
||||
Host implementations will replace this function during initialization
|
||||
with a concrete implementation providing actual functionality.
|
||||
|
||||
:param key: The key to remove.
|
||||
"""
|
||||
raise NotImplementedError("Function will be replaced during initialization")
|
||||
|
||||
|
||||
class _SharedStub:
|
||||
"""Stub for shared accessor that raises NotImplementedError on any operation."""
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
raise NotImplementedError("Accessor will be replaced during initialization")
|
||||
|
||||
|
||||
#: Shared (global) persistent state accessor.
|
||||
#: Data stored via this accessor is visible to all users of the extension.
|
||||
#: WARNING: Do not store user-specific or sensitive data here.
|
||||
#: Host implementations will replace this during initialization.
|
||||
shared: PersistentStateAccessor = _SharedStub()
|
||||
@@ -26,8 +26,13 @@ module.exports = (env, argv) => {
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx"],
|
||||
},
|
||||
externalsType: "window",
|
||||
externals: {
|
||||
"@apache-superset/core": "superset",
|
||||
externals: ({ request }, callback) => {
|
||||
// Map @apache-superset/core and subpaths to window.superset
|
||||
if (request?.startsWith("@apache-superset/core")) {
|
||||
const parts = request.replace("@apache-superset/core", "superset").split("/");
|
||||
return callback(null, parts);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
||||
@@ -45,6 +45,7 @@ src/
|
||||
├── extensions/
|
||||
├── menus/
|
||||
├── sqlLab/
|
||||
├── storage/
|
||||
├── theme/
|
||||
├── translation/
|
||||
├── utils/
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
"./testing": {
|
||||
"types": "./lib/testing.d.ts",
|
||||
"default": "./lib/testing.js"
|
||||
},
|
||||
"./storage": {
|
||||
"types": "./lib/storage/index.d.ts",
|
||||
"default": "./lib/storage/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -24,9 +24,114 @@
|
||||
* including querying extension metadata and monitoring extension lifecycle events.
|
||||
* Extensions can use this API to discover other extensions and react to changes
|
||||
* in the extension ecosystem.
|
||||
*
|
||||
* Extensions can access their own context via `getContext()`, which provides:
|
||||
* - Extension metadata (id, name, version, etc.)
|
||||
* - Extension-scoped storage (localStorage, sessionStorage, ephemeral cache)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { extensions } from '@apache-superset/core';
|
||||
*
|
||||
* // Get the current extension's context
|
||||
* const ctx = extensions.getContext();
|
||||
*
|
||||
* // Access extension metadata
|
||||
* console.log(`Running ${ctx.extension.name} v${ctx.extension.version}`);
|
||||
*
|
||||
* // Access extension-scoped storage
|
||||
* await ctx.storage.local.set('preference', { theme: 'dark' });
|
||||
* await ctx.storage.ephemeral.set('cache', data, { ttl: 300 });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Extension } from '../common';
|
||||
import { StorageTier } from '../storage/types';
|
||||
|
||||
/**
|
||||
* Extension-scoped storage accessor.
|
||||
*
|
||||
* All storage tiers are automatically namespaced to the current extension,
|
||||
* preventing key collisions between extensions.
|
||||
*/
|
||||
export interface ExtensionStorage {
|
||||
/**
|
||||
* Browser localStorage - persists across browser sessions.
|
||||
* Data is scoped to the current extension and user.
|
||||
*/
|
||||
local: StorageTier;
|
||||
|
||||
/**
|
||||
* Browser sessionStorage - cleared when the tab closes.
|
||||
* Data is scoped to the current extension and user.
|
||||
*/
|
||||
session: StorageTier;
|
||||
|
||||
/**
|
||||
* Server-side cache (Redis/Memcached) with TTL.
|
||||
* Data is scoped to the current extension and user.
|
||||
* Use `.shared` for data visible to all users.
|
||||
*/
|
||||
ephemeral: StorageTier;
|
||||
|
||||
/**
|
||||
* Durable database-backed storage (Tier 3).
|
||||
* Data survives server restarts and cache evictions.
|
||||
* Use `.shared` for data visible to all users.
|
||||
*/
|
||||
persistent: StorageTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context object providing extension-specific resources.
|
||||
*
|
||||
* This context is only available during extension execution.
|
||||
* Calling `getContext()` outside of an extension will throw an error.
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
/**
|
||||
* Metadata about the current extension.
|
||||
*/
|
||||
extension: Extension;
|
||||
|
||||
/**
|
||||
* Extension-scoped storage across all tiers.
|
||||
* All keys are automatically namespaced to prevent collisions.
|
||||
*/
|
||||
storage: ExtensionStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current extension's context.
|
||||
*
|
||||
* This function returns the context for the currently executing extension,
|
||||
* providing access to extension metadata and scoped resources like storage.
|
||||
*
|
||||
* @returns The current extension's context.
|
||||
* @throws Error if called outside of an extension context.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { extensions } from '@apache-superset/core';
|
||||
*
|
||||
* const ctx = extensions.getContext();
|
||||
*
|
||||
* // Access extension metadata
|
||||
* console.log(`Extension: ${ctx.extension.id}`);
|
||||
* console.log(`Version: ${ctx.extension.version}`);
|
||||
*
|
||||
* // Access extension-scoped storage
|
||||
* await ctx.storage.local.set('userPref', { sidebar: 'collapsed' });
|
||||
* const pref = await ctx.storage.local.get('userPref');
|
||||
*
|
||||
* // Use ephemeral storage with TTL
|
||||
* await ctx.storage.ephemeral.set('tempData', data, { ttl: 3600 });
|
||||
*
|
||||
* // Access shared (cross-user) storage
|
||||
* await ctx.storage.ephemeral.shared.set('globalCounter', count);
|
||||
* ```
|
||||
*/
|
||||
export declare function getContext(): ExtensionContext;
|
||||
|
||||
/**
|
||||
* Get an extension by its full identifier in the form of: `publisher.name`.
|
||||
|
||||
@@ -23,6 +23,7 @@ export * as editors from './editors';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
export * as sqlLab from './sqlLab';
|
||||
export * as storage from './storage';
|
||||
export * as views from './views';
|
||||
export * as contributions from './contributions';
|
||||
export * as theme from './theme';
|
||||
|
||||
@@ -62,6 +62,13 @@ export interface Tab {
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The stable backend-assigned ID for this tab (the tabstateview integer ID).
|
||||
* Set once the tab has been persisted to the backend. Undefined for new tabs
|
||||
* before the first backend sync.
|
||||
*/
|
||||
backendId?: string;
|
||||
|
||||
/**
|
||||
* The display title of the tab.
|
||||
* This is what users see in the tab header.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import type { JsonValue, StorageAccessor } from './types';
|
||||
|
||||
/**
|
||||
* @fileoverview Ephemeral State API for Superset extensions (Tier 2 Storage).
|
||||
*
|
||||
* Provides short-lived KV storage backed by the configured server-side cache
|
||||
* backend (Redis, Memcached, or filesystem). Automatically expires based on TTL.
|
||||
* Not guaranteed to survive server restarts.
|
||||
*
|
||||
* By default, all operations are user-scoped (private to the current user).
|
||||
* Use `shared` to access state that is visible to all users.
|
||||
*
|
||||
* Cache keys are namespaced automatically:
|
||||
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
|
||||
* - Shared (global): superset-ext:{extension_id}:{key}
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ephemeralState } from '@apache-superset/core/storage';
|
||||
*
|
||||
* // User-scoped state (default - private to current user)
|
||||
* const progress = await ephemeralState.get('job_progress');
|
||||
* await ephemeralState.set('job_progress', { pct: 42 }, { ttl: 300 });
|
||||
* await ephemeralState.remove('job_progress');
|
||||
*
|
||||
* // Shared state (explicit opt-in - visible to all users)
|
||||
* const result = await ephemeralState.shared.get('shared_result');
|
||||
* await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] });
|
||||
* await ephemeralState.shared.remove('shared_result');
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for setting ephemeral state values.
|
||||
*/
|
||||
export interface SetOptions {
|
||||
/**
|
||||
* Time-to-live in seconds. When omitted, the server uses CACHE_DEFAULT_TIMEOUT.
|
||||
*/
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for scoped ephemeral state access.
|
||||
* Extends StorageAccessor with TTL-specific options for set().
|
||||
*/
|
||||
export interface EphemeralStateAccessor extends StorageAccessor {
|
||||
/**
|
||||
* Set a value in scoped ephemeral state with TTL.
|
||||
*
|
||||
* @param key The key to store.
|
||||
* @param value The value to store (must be JSON-serializable).
|
||||
* @param options Optional settings including TTL.
|
||||
*/
|
||||
set(key: string, value: JsonValue, options?: SetOptions): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from user-scoped ephemeral state.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users cannot see or modify this data.
|
||||
*
|
||||
* @param key The key to retrieve.
|
||||
* @returns The stored value, or null if not found or expired.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const progress = await ephemeralState.get('job_progress');
|
||||
* if (progress !== null) {
|
||||
* updateProgressBar(progress.pct);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function get(key: string): Promise<JsonValue | null>;
|
||||
|
||||
/**
|
||||
* Set a value in user-scoped ephemeral state with TTL.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users cannot see or modify this data.
|
||||
*
|
||||
* @param key The key to store.
|
||||
* @param value The value to store (must be JSON-serializable).
|
||||
* @param options Optional settings including TTL (defaults to server CACHE_DEFAULT_TIMEOUT).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Store with server default TTL (CACHE_DEFAULT_TIMEOUT)
|
||||
* await ephemeralState.set('recent_items', ['item1', 'item2']);
|
||||
*
|
||||
* // Store with custom TTL (5 minutes)
|
||||
* await ephemeralState.set('temp_selection', data, { ttl: 300 });
|
||||
* ```
|
||||
*/
|
||||
export declare function set(
|
||||
key: string,
|
||||
value: JsonValue,
|
||||
options?: SetOptions,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove a value from user-scoped ephemeral state.
|
||||
*
|
||||
* @param key The key to remove.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await ephemeralState.remove('recent_items');
|
||||
* ```
|
||||
*/
|
||||
export declare function remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Shared (global) ephemeral state accessor.
|
||||
*
|
||||
* Accessor for state that is shared across all users.
|
||||
* Use this for data that needs to be visible to everyone, such as
|
||||
* job progress indicators or shared computation results.
|
||||
*
|
||||
* WARNING: Data stored via shared is visible to all users of the extension.
|
||||
* Do not store user-specific or sensitive data here.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get shared job progress
|
||||
* const progress = await ephemeralState.shared.get('computation_progress');
|
||||
*
|
||||
* // Update shared job progress
|
||||
* await ephemeralState.shared.set('computation_progress', { pct: 75 });
|
||||
*
|
||||
* // Clear shared state
|
||||
* await ephemeralState.shared.remove('computation_progress');
|
||||
* ```
|
||||
*/
|
||||
export declare const shared: EphemeralStateAccessor;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Storage API for Superset extensions.
|
||||
*
|
||||
* This module provides storage tiers for extensions:
|
||||
*
|
||||
* - **localState** (Tier 1): Browser localStorage - persists across sessions
|
||||
* - **sessionState** (Tier 1): Browser sessionStorage - cleared on tab close
|
||||
* - **ephemeralState** (Tier 2): Server-side cache with TTL - short-lived
|
||||
* - **persistentState** (Tier 3): Database storage - durable [future]
|
||||
*
|
||||
* All tiers follow the same API pattern:
|
||||
* - User-scoped by default (private to current user)
|
||||
* - `shared` accessor for data visible to all users
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { localState, sessionState, ephemeralState } from '@apache-superset/core/storage';
|
||||
*
|
||||
* // Tier 1 - localStorage (persists across browser sessions)
|
||||
* await localState.set('sidebar_collapsed', true);
|
||||
* const isCollapsed = await localState.get('sidebar_collapsed');
|
||||
*
|
||||
* // Tier 1 - sessionStorage (cleared on tab close)
|
||||
* await sessionState.set('wizard_step', 3);
|
||||
* const step = await sessionState.get('wizard_step');
|
||||
*
|
||||
* // Tier 2 - Server cache (short-lived, with TTL)
|
||||
* await ephemeralState.set('job_progress', { pct: 42 }, { ttl: 300 });
|
||||
* const progress = await ephemeralState.get('job_progress');
|
||||
*
|
||||
* // Shared state (visible to all users)
|
||||
* await localState.shared.set('device_id', 'abc-123');
|
||||
* await ephemeralState.shared.set('shared_result', { data: [1, 2, 3] });
|
||||
* ```
|
||||
*/
|
||||
|
||||
export * as localState from './localState';
|
||||
export * as sessionState from './sessionState';
|
||||
export * as ephemeralState from './ephemeralState';
|
||||
export * as persistentState from './persistentState';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Local State API for Superset extensions (Tier 1 Storage).
|
||||
*
|
||||
* Provides client-side KV storage backed by the browser's localStorage.
|
||||
* Data persists across browser sessions but is per-device (not shared across
|
||||
* devices or synced to the server).
|
||||
*
|
||||
* By default, all operations are user-scoped (private to the current user).
|
||||
* Use `shared` to access state visible to all users on the same browser.
|
||||
*
|
||||
* Key patterns:
|
||||
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
|
||||
* - Shared: superset-ext:{extension_id}:{key}
|
||||
*
|
||||
* The API is async to maintain compatibility with a future sandboxed execution
|
||||
* model where storage calls would go through a postMessage bridge.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { localState } from '@apache-superset/core/storage';
|
||||
*
|
||||
* // User-scoped state (default - private to current user)
|
||||
* const isCollapsed = await localState.get('sidebar_collapsed');
|
||||
* await localState.set('sidebar_collapsed', true);
|
||||
* await localState.remove('sidebar_collapsed');
|
||||
*
|
||||
* // Shared state (visible to all users on same browser)
|
||||
* const deviceId = await localState.shared.get('device_id');
|
||||
* await localState.shared.set('device_id', 'abc-123');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { JsonValue, StorageAccessor } from './types';
|
||||
|
||||
/**
|
||||
* Get a value from user-scoped local state.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users on the same browser cannot see or modify this data.
|
||||
*
|
||||
* @param key The key to retrieve.
|
||||
* @returns The stored value, or null if not found.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const isCollapsed = await localState.get('sidebar_collapsed');
|
||||
* if (isCollapsed) {
|
||||
* collapseSidebar();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function get(key: string): Promise<JsonValue | null>;
|
||||
|
||||
/**
|
||||
* Set a value in user-scoped local state.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users on the same browser cannot see or modify this data.
|
||||
*
|
||||
* @param key The key to store.
|
||||
* @param value The value to store (must be JSON-serializable).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await localState.set('sidebar_collapsed', true);
|
||||
* await localState.set('panel_width', 300);
|
||||
* ```
|
||||
*/
|
||||
export declare function set(key: string, value: JsonValue): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove a value from user-scoped local state.
|
||||
*
|
||||
* @param key The key to remove.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await localState.remove('sidebar_collapsed');
|
||||
* ```
|
||||
*/
|
||||
export declare function remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Shared local state accessor.
|
||||
*
|
||||
* Accessor for state that is shared across all users on the
|
||||
* same browser/device. Use this for device-specific settings that should
|
||||
* persist regardless of which user is logged in.
|
||||
*
|
||||
* WARNING: Data stored via shared is visible to all users on this browser.
|
||||
* Do not store user-specific or sensitive data here.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get device-specific setting
|
||||
* const deviceId = await localState.shared.get('device_id');
|
||||
*
|
||||
* // Set device-specific setting
|
||||
* await localState.shared.set('last_used_printer', 'HP-1234');
|
||||
* ```
|
||||
*/
|
||||
export declare const shared: StorageAccessor;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import type { JsonValue, StorageAccessor } from './types';
|
||||
|
||||
/**
|
||||
* @fileoverview Persistent State API for Superset extensions (Tier 3 Storage).
|
||||
*
|
||||
* Provides durable KV storage backed by a dedicated database table.
|
||||
* Data survives server restarts, cache evictions, and browser clears.
|
||||
* Suitable for user preferences, saved state, and any data that must
|
||||
* not be lost.
|
||||
*
|
||||
* By default, all operations are user-scoped (private to the current user).
|
||||
* Use `shared` to access state that is visible to all users of the extension.
|
||||
*
|
||||
* Database keys are namespaced automatically:
|
||||
* - User-scoped (default): (extension_id, user_id, key)
|
||||
* - Shared (global): (extension_id, null, key)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { persistentState } from '@apache-superset/core/storage';
|
||||
*
|
||||
* // User-scoped state (default - private to current user)
|
||||
* const prefs = await persistentState.get('preferences');
|
||||
* await persistentState.set('preferences', { theme: 'dark', locale: 'en' });
|
||||
* await persistentState.remove('preferences');
|
||||
*
|
||||
* // Shared state (explicit opt-in - visible to all users)
|
||||
* const config = await persistentState.shared.get('global_config');
|
||||
* await persistentState.shared.set('global_config', { version: 2 });
|
||||
* await persistentState.shared.remove('global_config');
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get a value from user-scoped persistent state.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users cannot see or modify this data.
|
||||
*
|
||||
* @param key The key to retrieve.
|
||||
* @returns The stored value, or null if not found.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const prefs = await persistentState.get('preferences');
|
||||
* if (prefs !== null) {
|
||||
* applyPreferences(prefs);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function get(key: string): Promise<JsonValue | null>;
|
||||
|
||||
/**
|
||||
* Set a value in user-scoped persistent state.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users cannot see or modify this data.
|
||||
* Data persists indefinitely until explicitly removed.
|
||||
*
|
||||
* @param key The key to store.
|
||||
* @param value The value to store (must be JSON-serializable).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await persistentState.set('preferences', { theme: 'dark', locale: 'en' });
|
||||
* ```
|
||||
*/
|
||||
export declare function set(key: string, value: JsonValue): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove a value from user-scoped persistent state.
|
||||
*
|
||||
* @param key The key to remove.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await persistentState.remove('preferences');
|
||||
* ```
|
||||
*/
|
||||
export declare function remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Shared (global) persistent state accessor.
|
||||
*
|
||||
* Accessor for state that is shared across all users of the extension.
|
||||
* Use this for extension-wide configuration, shared datasets, or any
|
||||
* data that should be accessible to all users regardless of identity.
|
||||
*
|
||||
* WARNING: Data stored via shared is visible to all users of the extension.
|
||||
* Do not store user-specific or sensitive data here.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Read shared extension config
|
||||
* const config = await persistentState.shared.get('global_config');
|
||||
*
|
||||
* // Update shared config (typically admin-only)
|
||||
* await persistentState.shared.set('global_config', { version: 2 });
|
||||
*
|
||||
* // Remove shared config entry
|
||||
* await persistentState.shared.remove('global_config');
|
||||
* ```
|
||||
*/
|
||||
export declare const shared: StorageAccessor;
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Session State API for Superset extensions (Tier 1 Storage).
|
||||
*
|
||||
* Provides client-side KV storage backed by the browser's sessionStorage.
|
||||
* Data is cleared when the browser tab/window is closed. Use this for
|
||||
* truly transient UI state that should not persist across sessions.
|
||||
*
|
||||
* By default, all operations are user-scoped (private to the current user).
|
||||
* Use `shared` to access state visible to all users on the same browser tab.
|
||||
*
|
||||
* Key patterns:
|
||||
* - User-scoped (default): superset-ext:{extension_id}:user:{user_id}:{key}
|
||||
* - Shared: superset-ext:{extension_id}:{key}
|
||||
*
|
||||
* The API is async to maintain compatibility with a future sandboxed execution
|
||||
* model where storage calls would go through a postMessage bridge.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { sessionState } from '@apache-superset/core/storage';
|
||||
*
|
||||
* // User-scoped state (default - private to current user, cleared on tab close)
|
||||
* const wizardStep = await sessionState.get('wizard_step');
|
||||
* await sessionState.set('wizard_step', 3);
|
||||
* await sessionState.remove('wizard_step');
|
||||
*
|
||||
* // Shared state (visible to all users on same tab)
|
||||
* const tempData = await sessionState.shared.get('temp_data');
|
||||
* await sessionState.shared.set('temp_data', { draft: true });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { JsonValue, StorageAccessor } from './types';
|
||||
|
||||
/**
|
||||
* Get a value from user-scoped session state.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users on the same browser tab cannot see or modify this data.
|
||||
* Data is cleared when the tab/window is closed.
|
||||
*
|
||||
* @param key The key to retrieve.
|
||||
* @returns The stored value, or null if not found.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const wizardStep = await sessionState.get('wizard_step');
|
||||
* if (wizardStep !== null) {
|
||||
* resumeWizard(wizardStep);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export declare function get(key: string): Promise<JsonValue | null>;
|
||||
|
||||
/**
|
||||
* Set a value in user-scoped session state.
|
||||
*
|
||||
* Data is automatically scoped to the current authenticated user.
|
||||
* Other users on the same browser tab cannot see or modify this data.
|
||||
* Data is cleared when the tab/window is closed.
|
||||
*
|
||||
* @param key The key to store.
|
||||
* @param value The value to store (must be JSON-serializable).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await sessionState.set('wizard_step', 3);
|
||||
* await sessionState.set('unsaved_form', formData);
|
||||
* ```
|
||||
*/
|
||||
export declare function set(key: string, value: JsonValue): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove a value from user-scoped session state.
|
||||
*
|
||||
* @param key The key to remove.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await sessionState.remove('wizard_step');
|
||||
* ```
|
||||
*/
|
||||
export declare function remove(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Shared session state accessor.
|
||||
*
|
||||
* Accessor for state that is shared across all users on the
|
||||
* same browser tab. Data is cleared when the tab/window is closed.
|
||||
*
|
||||
* WARNING: Data stored via shared is visible to all users on this tab.
|
||||
* Do not store user-specific or sensitive data here.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Store temporary shared data
|
||||
* await sessionState.shared.set('temp_computation', result);
|
||||
*
|
||||
* // Retrieve temporary shared data
|
||||
* const result = await sessionState.shared.get('temp_computation');
|
||||
* ```
|
||||
*/
|
||||
export declare const shared: StorageAccessor;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Shared types for extension storage APIs.
|
||||
*
|
||||
* These types are shared across all storage tiers (local, session, ephemeral,
|
||||
* persistent) to ensure a consistent API pattern.
|
||||
*/
|
||||
|
||||
/**
|
||||
* JSON-compatible value type.
|
||||
* These are the only values that can be safely serialized/deserialized via JSON.
|
||||
*/
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Base interface for a storage accessor.
|
||||
* All storage tiers implement this interface for both user-scoped and shared access.
|
||||
*/
|
||||
export interface StorageAccessor {
|
||||
/**
|
||||
* Get a value from storage.
|
||||
*
|
||||
* @param key The key to retrieve.
|
||||
* @returns The stored value, or null if not found.
|
||||
*/
|
||||
get(key: string): Promise<JsonValue | null>;
|
||||
|
||||
/**
|
||||
* Set a value in storage.
|
||||
*
|
||||
* @param key The key to store.
|
||||
* @param value The value to store (must be JSON-serializable).
|
||||
* @param options Optional settings (varies by tier).
|
||||
*/
|
||||
set(
|
||||
key: string,
|
||||
value: JsonValue,
|
||||
options?: Record<string, JsonValue>,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Remove a value from storage.
|
||||
*
|
||||
* @param key The key to remove.
|
||||
*/
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for a storage tier.
|
||||
* All storage tiers implement this interface with user-scoped default and shared accessor.
|
||||
*/
|
||||
export interface StorageTier extends StorageAccessor {
|
||||
/**
|
||||
* Shared storage accessor.
|
||||
* Data stored via shared is visible to all users.
|
||||
*/
|
||||
shared: StorageAccessor;
|
||||
}
|
||||
@@ -120,6 +120,15 @@ import KeyboardShortcutButton, {
|
||||
KeyboardShortcut,
|
||||
} from '../KeyboardShortcutButton';
|
||||
import SqlEditorTopBar from '../SqlEditorTopBar';
|
||||
import {
|
||||
ViewLocations,
|
||||
PENDING_NORTH_PANE_VIEW_KEY,
|
||||
} from 'src/SqlLab/contributions';
|
||||
import { views } from 'src/core';
|
||||
import { resolveView, onViewsChange } from 'src/core/views';
|
||||
|
||||
/** Per-tab localStorage key storing the active northPane view ID. */
|
||||
const NORTH_PANE_VIEW_KEY = (tabId: string) => `sqllab.northPaneView.${tabId}`;
|
||||
|
||||
const bootstrapData = getBootstrapData();
|
||||
const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
|
||||
@@ -270,6 +279,52 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const logAction = useLogAction({ queryEditorId: queryEditor.id });
|
||||
const isActive = currentQueryEditorId === queryEditor.id;
|
||||
|
||||
const [northPaneViews, setNorthPaneViews] = useState(
|
||||
() => views.getViews(ViewLocations.sqllab.northPane) || [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onViewsChange(ViewLocations.sqllab.northPane, () => {
|
||||
setNorthPaneViews(views.getViews(ViewLocations.sqllab.northPane) || []);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// ID of the northPane view active for this tab, or null for the default
|
||||
// SQL editor layout. Set by an extension via PENDING_NORTH_PANE_VIEW_KEY
|
||||
// before calling createTab(); persisted per-tab in localStorage.
|
||||
const [northPaneViewId, setNorthPaneViewId] = useState<string | null>(() => {
|
||||
const pendingViewId = localStorage.getItem(PENDING_NORTH_PANE_VIEW_KEY);
|
||||
if (pendingViewId) {
|
||||
localStorage.removeItem(PENDING_NORTH_PANE_VIEW_KEY);
|
||||
localStorage.setItem(NORTH_PANE_VIEW_KEY(queryEditor.id), pendingViewId);
|
||||
return pendingViewId;
|
||||
}
|
||||
return localStorage.getItem(NORTH_PANE_VIEW_KEY(queryEditor.id));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const persistKey = NORTH_PANE_VIEW_KEY(
|
||||
queryEditor.tabViewId ?? queryEditor.id,
|
||||
);
|
||||
if (northPaneViewId) {
|
||||
localStorage.setItem(persistKey, northPaneViewId);
|
||||
} else {
|
||||
localStorage.removeItem(persistKey);
|
||||
}
|
||||
}, [queryEditor.tabViewId, queryEditor.id, northPaneViewId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === NORTH_PANE_VIEW_KEY(queryEditor.id)) {
|
||||
setNorthPaneViewId(e.newValue || null);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
return () => window.removeEventListener('storage', handler);
|
||||
}, [queryEditor.id]);
|
||||
|
||||
const [autorun, setAutorun] = useState(queryEditor.autorun);
|
||||
const [ctas, setCtas] = useState('');
|
||||
const [northPercent, setNorthPercent] = useState(
|
||||
@@ -1042,6 +1097,30 @@ const SqlEditor: FC<Props> = ({
|
||||
'Choose one of the available databases from the panel on the left.',
|
||||
)}
|
||||
/>
|
||||
) : northPaneViewId &&
|
||||
northPaneViews.some(v => v.id === northPaneViewId) ? (
|
||||
<div
|
||||
css={css`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`}
|
||||
>
|
||||
<SqlEditorTopBar
|
||||
queryEditorId={queryEditor.id}
|
||||
defaultPrimaryActions={null}
|
||||
defaultSecondaryActions={[]}
|
||||
/>
|
||||
<div
|
||||
css={css`
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0 ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
{resolveView(northPaneViewId)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
queryPane()
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import { PureComponent, useState, useMemo } from 'react';
|
||||
import { EditableTabs } from '@superset-ui/core/components/Tabs';
|
||||
import { connect } from 'react-redux';
|
||||
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
@@ -24,10 +24,13 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { Logger } from 'src/logger/LogUtils';
|
||||
import { EmptyState, Tooltip } from '@superset-ui/core/components';
|
||||
import { Dropdown, EmptyState, Tooltip } from '@superset-ui/core/components';
|
||||
import { MenuItemType } from '@superset-ui/core/components/Menu';
|
||||
import { detectOS } from 'src/utils/common';
|
||||
import * as Actions from 'src/SqlLab/actions/sqlLab';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { menus, commands } from 'src/core';
|
||||
import { ViewLocations } from 'src/SqlLab/contributions';
|
||||
import SqlEditor from '../SqlEditor';
|
||||
import SqlEditorTabHeader from '../SqlEditorTabHeader';
|
||||
|
||||
@@ -92,6 +95,86 @@ const TabTitle = styled.span`
|
||||
// Get the user's OS
|
||||
const userOS = detectOS();
|
||||
|
||||
const newTabTooltip =
|
||||
userOS === 'Windows' ? t('New tab (Ctrl + q)') : t('New tab (Ctrl + t)');
|
||||
|
||||
const PlusIcon = (
|
||||
<Icons.PlusOutlined
|
||||
iconSize="l"
|
||||
css={css`
|
||||
vertical-align: middle;
|
||||
`}
|
||||
data-test="add-tab-icon"
|
||||
/>
|
||||
);
|
||||
|
||||
function NewTabButton({ onAddSqlEditor }: { onAddSqlEditor: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const dropdownItems = useMemo<MenuItemType[]>(() => {
|
||||
if (!open) return [];
|
||||
const primaryItems =
|
||||
menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? [];
|
||||
return [
|
||||
{
|
||||
key: 'sql-editor',
|
||||
label: t('SQL Editor'),
|
||||
icon: <Icons.TableOutlined iconSize="m" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
onAddSqlEditor();
|
||||
},
|
||||
},
|
||||
...primaryItems.map(item => {
|
||||
const command = commands.getCommand(item.command);
|
||||
const Icon = command?.icon
|
||||
? ((Icons as Record<string, typeof Icons.FileOutlined>)[
|
||||
command.icon
|
||||
] ?? Icons.FileOutlined)
|
||||
: Icons.FileOutlined;
|
||||
return {
|
||||
key: command?.id ?? item.command,
|
||||
label: command?.title ?? item.command,
|
||||
icon: <Icon iconSize="m" />,
|
||||
onClick: () => {
|
||||
setOpen(false);
|
||||
commands.executeCommand(item.command);
|
||||
},
|
||||
} as MenuItemType;
|
||||
}),
|
||||
];
|
||||
}, [open, onAddSqlEditor]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Antd's Tabs wraps addIcon in its own <button onClick={() => onEdit('add')}>.
|
||||
// Stop propagation so antd doesn't also call newQueryEditor() while we handle it.
|
||||
e.stopPropagation();
|
||||
const primaryItems =
|
||||
menus.getMenu(ViewLocations.sqllab.newTab)?.primary ?? [];
|
||||
if (primaryItems.length === 0) {
|
||||
onAddSqlEditor();
|
||||
} else {
|
||||
setOpen(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip id="add-tab" placement="left" title={newTabTooltip}>
|
||||
<Dropdown
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
menu={{ items: dropdownItems }}
|
||||
trigger={[]}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
|
||||
<span role="button" tabIndex={0} onClick={handleClick}>
|
||||
{PlusIcon}
|
||||
</span>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
|
||||
|
||||
class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
|
||||
@@ -231,25 +314,7 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
|
||||
onTabClick={this.onTabClicked}
|
||||
onEdit={this.handleEdit}
|
||||
type={this.props.queryEditors?.length === 0 ? 'card' : 'editable-card'}
|
||||
addIcon={
|
||||
<Tooltip
|
||||
id="add-tab"
|
||||
placement="left"
|
||||
title={
|
||||
userOS === 'Windows'
|
||||
? t('New tab (Ctrl + q)')
|
||||
: t('New tab (Ctrl + t)')
|
||||
}
|
||||
>
|
||||
<Icons.PlusOutlined
|
||||
iconSize="l"
|
||||
css={css`
|
||||
vertical-align: middle;
|
||||
`}
|
||||
data-test="add-tab-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
addIcon={<NewTabButton onAddSqlEditor={() => this.newQueryEditor()} />}
|
||||
items={tabItems}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -46,5 +46,27 @@ export const ViewLocations = {
|
||||
statusBar: 'sqllab.statusBar',
|
||||
results: 'sqllab.results',
|
||||
queryHistory: 'sqllab.queryHistory',
|
||||
// Extensions can register a full-pane replacement here. SqlEditor renders
|
||||
// the registered view instead of the default editor+SouthPane split when
|
||||
// a tab was opened in that mode.
|
||||
northPane: 'sqllab.northPane',
|
||||
// Extensions register tab-type commands here. When any are present the
|
||||
// "+" new-tab button becomes a dropdown listing all registered tab types
|
||||
// plus the built-in SQL Editor option.
|
||||
newTab: 'sqllab.newTab',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* localStorage key an extension sets before calling createTab() to declare
|
||||
* which northPane view the new tab should open with. The value must be the
|
||||
* view ID passed to views.registerView() (e.g. "my-ext.northPane"). SqlEditor
|
||||
* consumes and removes this key during initialization, then persists the chosen
|
||||
* view ID under a per-tab key so the mode survives page reloads.
|
||||
*
|
||||
* @example
|
||||
* // In an extension's newTab command handler:
|
||||
* localStorage.setItem(PENDING_NORTH_PANE_VIEW_KEY, 'my-ext.northPane');
|
||||
* sqlLab.createTab({ title: 'My View' });
|
||||
*/
|
||||
export const PENDING_NORTH_PANE_VIEW_KEY = 'sqllab.pendingNorthPaneView';
|
||||
|
||||
@@ -55,7 +55,7 @@ const PanelToolbar = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={item.view}
|
||||
key={item.command}
|
||||
onClick={() => commands.executeCommand(command?.id)}
|
||||
tooltip={command?.description ?? command?.title}
|
||||
icon={<Icon iconSize="m" />}
|
||||
|
||||
26
superset-frontend/src/core/extensions/index.test.ts
Normal file
26
superset-frontend/src/core/extensions/index.test.ts
Normal file
@@ -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.
|
||||
*/
|
||||
|
||||
import { extensions as extensionsImpl } from './index';
|
||||
|
||||
test('extensions.getContext throws when not in extension context', () => {
|
||||
expect(() => extensionsImpl.getContext()).toThrow(
|
||||
'getContext() must be called within an extension context',
|
||||
);
|
||||
});
|
||||
@@ -16,9 +16,21 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { extensions as extensionsApi } from '@apache-superset/core';
|
||||
import type { extensions as extensionsApi } from '@apache-superset/core';
|
||||
import ExtensionsLoader from 'src/extensions/ExtensionsLoader';
|
||||
|
||||
export {
|
||||
createExtensionContext,
|
||||
createBoundGetContext,
|
||||
} from 'src/extensions/ExtensionContext';
|
||||
|
||||
const getContext: typeof extensionsApi.getContext = () => {
|
||||
throw new Error(
|
||||
'getContext() must be called within an extension context. ' +
|
||||
'Ensure this code is being executed by an extension.',
|
||||
);
|
||||
};
|
||||
|
||||
const getExtension: typeof extensionsApi.getExtension = id =>
|
||||
ExtensionsLoader.getInstance().getExtension(id);
|
||||
|
||||
@@ -26,6 +38,7 @@ const getAllExtensions: typeof extensionsApi.getAllExtensions = () =>
|
||||
ExtensionsLoader.getInstance().getExtensions();
|
||||
|
||||
export const extensions: typeof extensionsApi = {
|
||||
getContext,
|
||||
getExtension,
|
||||
getAllExtensions,
|
||||
};
|
||||
|
||||
@@ -33,5 +33,6 @@ export * from './extensions';
|
||||
export * from './menus';
|
||||
export * from './models';
|
||||
export * from './sqlLab';
|
||||
export * from './storage';
|
||||
export * from './utils';
|
||||
export * from './views';
|
||||
|
||||
85
superset-frontend/src/core/storage/ephemeralState.ts
Normal file
85
superset-frontend/src/core/storage/ephemeralState.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { type storage as StorageTypes } from '@apache-superset/core';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* Create ephemeral state (server cache) bound to an extension ID.
|
||||
*/
|
||||
export function createEphemeralState(
|
||||
extensionId: string,
|
||||
): typeof StorageTypes.ephemeralState {
|
||||
const buildUrl = (key: string, shared?: boolean): string => {
|
||||
const basePath = '/api/v1/extensions/storage/ephemeral';
|
||||
const encodedId = encodeURIComponent(extensionId);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
const url = `${basePath}/${encodedId}/${encodedKey}`;
|
||||
return shared ? `${url}?shared=true` : url;
|
||||
};
|
||||
|
||||
const shared: StorageTypes.ephemeralState.EphemeralStateAccessor = {
|
||||
get: async (key: string) => {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: buildUrl(key, true),
|
||||
});
|
||||
return response.json?.result ?? null;
|
||||
},
|
||||
set: async (
|
||||
key: string,
|
||||
value: StorageTypes.JsonValue,
|
||||
options?: StorageTypes.ephemeralState.SetOptions,
|
||||
) => {
|
||||
const body: Record<string, unknown> = { value };
|
||||
if (options?.ttl !== undefined) body.ttl = options.ttl;
|
||||
await SupersetClient.put({
|
||||
endpoint: buildUrl(key, true),
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
await SupersetClient.delete({ endpoint: buildUrl(key, true) });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
get: async (key: string) => {
|
||||
const response = await SupersetClient.get({ endpoint: buildUrl(key) });
|
||||
return response.json?.result ?? null;
|
||||
},
|
||||
set: async (
|
||||
key: string,
|
||||
value: StorageTypes.JsonValue,
|
||||
options?: StorageTypes.ephemeralState.SetOptions,
|
||||
) => {
|
||||
const body: Record<string, unknown> = { value };
|
||||
if (options?.ttl !== undefined) body.ttl = options.ttl;
|
||||
await SupersetClient.put({
|
||||
endpoint: buildUrl(key),
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
await SupersetClient.delete({ endpoint: buildUrl(key) });
|
||||
},
|
||||
shared,
|
||||
};
|
||||
}
|
||||
27
superset-frontend/src/core/storage/index.ts
Normal file
27
superset-frontend/src/core/storage/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Storage implementation for extensions.
|
||||
* Extensions access storage via getContext().storage which is bound to each extension.
|
||||
*/
|
||||
|
||||
export { createBrowserStorage } from './localState';
|
||||
export { createEphemeralState } from './ephemeralState';
|
||||
export { createPersistentState } from './persistentState';
|
||||
80
superset-frontend/src/core/storage/localState.ts
Normal file
80
superset-frontend/src/core/storage/localState.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { type storage as StorageTypes } from '@apache-superset/core';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
const KEY_PREFIX = 'superset-ext';
|
||||
|
||||
function getCurrentUserId(): number {
|
||||
const bootstrapData = getBootstrapData();
|
||||
const userId = bootstrapData?.user?.userId;
|
||||
if (userId === undefined) {
|
||||
throw new Error('Storage APIs require an authenticated user.');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function buildKey(...parts: (string | number)[]): string {
|
||||
return [KEY_PREFIX, ...parts].join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create browser storage (localStorage/sessionStorage) bound to an extension ID.
|
||||
*/
|
||||
export function createBrowserStorage(
|
||||
storage: Storage,
|
||||
extensionId: string,
|
||||
): typeof StorageTypes.localState {
|
||||
const shared: StorageTypes.StorageAccessor = {
|
||||
get: async (key: string) => {
|
||||
const storageKey = buildKey(extensionId, key);
|
||||
const value = storage.getItem(storageKey);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
set: async (key: string, value: StorageTypes.JsonValue) => {
|
||||
const storageKey = buildKey(extensionId, key);
|
||||
storage.setItem(storageKey, JSON.stringify(value));
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
const storageKey = buildKey(extensionId, key);
|
||||
storage.removeItem(storageKey);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
get: async (key: string) => {
|
||||
const userId = getCurrentUserId();
|
||||
const storageKey = buildKey(extensionId, 'user', userId, key);
|
||||
const value = storage.getItem(storageKey);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
set: async (key: string, value: StorageTypes.JsonValue) => {
|
||||
const userId = getCurrentUserId();
|
||||
const storageKey = buildKey(extensionId, 'user', userId, key);
|
||||
storage.setItem(storageKey, JSON.stringify(value));
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
const userId = getCurrentUserId();
|
||||
const storageKey = buildKey(extensionId, 'user', userId, key);
|
||||
storage.removeItem(storageKey);
|
||||
},
|
||||
shared,
|
||||
};
|
||||
}
|
||||
80
superset-frontend/src/core/storage/persistentState.ts
Normal file
80
superset-frontend/src/core/storage/persistentState.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { type storage as StorageTypes } from '@apache-superset/core';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* Create persistent state (database-backed) bound to an extension ID.
|
||||
*/
|
||||
export function createPersistentState(
|
||||
extensionId: string,
|
||||
): typeof StorageTypes.persistentState {
|
||||
const MAX_KEY_LENGTH = 255;
|
||||
|
||||
const buildUrl = (key: string, shared?: boolean): string => {
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new Error(
|
||||
`Persistent storage key must be ${MAX_KEY_LENGTH} characters or less.`,
|
||||
);
|
||||
}
|
||||
const basePath = '/api/v1/extensions/storage/persistent';
|
||||
const encodedId = encodeURIComponent(extensionId);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
const url = `${basePath}/${encodedId}/${encodedKey}`;
|
||||
return shared ? `${url}?shared=true` : url;
|
||||
};
|
||||
|
||||
const shared: StorageTypes.StorageAccessor = {
|
||||
get: async (key: string) => {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: buildUrl(key, true),
|
||||
});
|
||||
return response.json?.result ?? null;
|
||||
},
|
||||
set: async (key: string, value: StorageTypes.JsonValue) => {
|
||||
await SupersetClient.put({
|
||||
endpoint: buildUrl(key, true),
|
||||
body: JSON.stringify({ value }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
await SupersetClient.delete({ endpoint: buildUrl(key, true) });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
get: async (key: string) => {
|
||||
const response = await SupersetClient.get({ endpoint: buildUrl(key) });
|
||||
return response.json?.result ?? null;
|
||||
},
|
||||
set: async (key: string, value: StorageTypes.JsonValue) => {
|
||||
await SupersetClient.put({
|
||||
endpoint: buildUrl(key),
|
||||
body: JSON.stringify({ value }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
remove: async (key: string) => {
|
||||
await SupersetClient.delete({ endpoint: buildUrl(key) });
|
||||
},
|
||||
shared,
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,24 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = new Map();
|
||||
|
||||
// Subscribers notified when views at a specific location change
|
||||
const locationListeners: Map<string, Set<() => void>> = new Map();
|
||||
|
||||
/**
|
||||
* Subscribe to view registrations at a given location.
|
||||
* Returns an unsubscribe function. Useful for components that need to
|
||||
* re-render when an extension registers a view after async load.
|
||||
*/
|
||||
export const onViewsChange = (
|
||||
location: string,
|
||||
cb: () => void,
|
||||
): (() => void) => {
|
||||
const listeners = locationListeners.get(location) ?? new Set();
|
||||
listeners.add(cb);
|
||||
locationListeners.set(location, listeners);
|
||||
return () => listeners.delete(cb);
|
||||
};
|
||||
|
||||
const registerView: typeof viewsApi.registerView = (
|
||||
view: View,
|
||||
location: string,
|
||||
@@ -52,9 +70,13 @@ const registerView: typeof viewsApi.registerView = (
|
||||
ids.add(id);
|
||||
locationIndex.set(location, ids);
|
||||
|
||||
// Notify any React components waiting on this location
|
||||
locationListeners.get(location)?.forEach(cb => cb());
|
||||
|
||||
return new Disposable(() => {
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
locationListeners.get(location)?.forEach(cb => cb());
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
87
superset-frontend/src/extensions/ExtensionContext.test.ts
Normal file
87
superset-frontend/src/extensions/ExtensionContext.test.ts
Normal file
@@ -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.
|
||||
*/
|
||||
|
||||
import type { common } from '@apache-superset/core';
|
||||
import {
|
||||
createExtensionContext,
|
||||
createBoundGetContext,
|
||||
} from './ExtensionContext';
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ user: { userId: 1 } })),
|
||||
}));
|
||||
|
||||
const createMockExtension = (id: string): common.Extension =>
|
||||
({
|
||||
id,
|
||||
name: `test-${id}`,
|
||||
publisher: 'test',
|
||||
}) as unknown as common.Extension;
|
||||
|
||||
test('createExtensionContext creates context with extension metadata', () => {
|
||||
const extension = createMockExtension('test.ext');
|
||||
const ctx = createExtensionContext(extension);
|
||||
|
||||
expect(ctx.extension).toBe(extension);
|
||||
expect(ctx.extension.id).toBe('test.ext');
|
||||
});
|
||||
|
||||
test('createExtensionContext creates context with lazy storage', () => {
|
||||
const extension = createMockExtension('test.ext');
|
||||
const ctx = createExtensionContext(extension);
|
||||
|
||||
expect(ctx.storage).toBeDefined();
|
||||
expect(ctx.storage.local).toBeDefined();
|
||||
expect(ctx.storage.session).toBeDefined();
|
||||
expect(ctx.storage.ephemeral).toBeDefined();
|
||||
});
|
||||
|
||||
test('createBoundGetContext returns function that always returns same context', () => {
|
||||
const extension = createMockExtension('test.ext');
|
||||
const ctx = createExtensionContext(extension);
|
||||
const getContext = createBoundGetContext(ctx);
|
||||
|
||||
expect(getContext()).toBe(ctx);
|
||||
expect(getContext()).toBe(ctx);
|
||||
});
|
||||
|
||||
test('createBoundGetContext works in async code', async () => {
|
||||
const extension = createMockExtension('test.ext');
|
||||
const ctx = createExtensionContext(extension);
|
||||
const getContext = createBoundGetContext(ctx);
|
||||
|
||||
const result = await Promise.resolve().then(() => getContext());
|
||||
|
||||
expect(result).toBe(ctx);
|
||||
});
|
||||
|
||||
test('different extensions get different bound contexts', () => {
|
||||
const ext1 = createMockExtension('org1.ext1');
|
||||
const ext2 = createMockExtension('org2.ext2');
|
||||
|
||||
const ctx1 = createExtensionContext(ext1);
|
||||
const ctx2 = createExtensionContext(ext2);
|
||||
|
||||
const getContext1 = createBoundGetContext(ctx1);
|
||||
const getContext2 = createBoundGetContext(ctx2);
|
||||
|
||||
expect(getContext1().extension.id).toBe('org1.ext1');
|
||||
expect(getContext2().extension.id).toBe('org2.ext2');
|
||||
});
|
||||
74
superset-frontend/src/extensions/ExtensionContext.ts
Normal file
74
superset-frontend/src/extensions/ExtensionContext.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import type {
|
||||
extensions as extensionsApi,
|
||||
common,
|
||||
} from '@apache-superset/core';
|
||||
import {
|
||||
createBrowserStorage,
|
||||
createEphemeralState,
|
||||
createPersistentState,
|
||||
} from 'src/core/storage';
|
||||
|
||||
type ExtensionContextType = extensionsApi.ExtensionContext;
|
||||
type Extension = common.Extension;
|
||||
|
||||
/**
|
||||
* Extension context with lazy-initialized services bound to the extension ID.
|
||||
*/
|
||||
class ExtensionContext implements ExtensionContextType {
|
||||
readonly extension: Extension;
|
||||
|
||||
private _storage?: ExtensionContextType['storage'];
|
||||
|
||||
constructor(extension: Extension) {
|
||||
this.extension = extension;
|
||||
}
|
||||
|
||||
get storage(): ExtensionContextType['storage'] {
|
||||
if (!this._storage) {
|
||||
const { id } = this.extension;
|
||||
this._storage = {
|
||||
local: createBrowserStorage(localStorage, id),
|
||||
session: createBrowserStorage(sessionStorage, id),
|
||||
ephemeral: createEphemeralState(id),
|
||||
persistent: createPersistentState(id),
|
||||
};
|
||||
}
|
||||
return this._storage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an extension context.
|
||||
*/
|
||||
export function createExtensionContext(
|
||||
extension: Extension,
|
||||
): ExtensionContextType {
|
||||
return new ExtensionContext(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a bound getContext function for an extension.
|
||||
*/
|
||||
export function createBoundGetContext(
|
||||
ctx: ExtensionContextType,
|
||||
): typeof extensionsApi.getContext {
|
||||
return () => ctx;
|
||||
}
|
||||
@@ -19,6 +19,11 @@
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import type { common as core } from '@apache-superset/core';
|
||||
import {
|
||||
createExtensionContext,
|
||||
createBoundGetContext,
|
||||
} from './ExtensionContext';
|
||||
import './types';
|
||||
|
||||
type Extension = core.Extension;
|
||||
|
||||
@@ -132,11 +137,17 @@ class ExtensionsLoader {
|
||||
const containerName = (extension as any).moduleFederationName || id;
|
||||
const container = (window as any)[containerName];
|
||||
|
||||
// Create extension context with bound storage
|
||||
const context = createExtensionContext(extension);
|
||||
const boundGetContext = createBoundGetContext(context);
|
||||
|
||||
// Provide bound getContext to this extension via window.superset.extensions
|
||||
window.superset.extensions.getContext = boundGetContext;
|
||||
|
||||
// @ts-expect-error
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
|
||||
const factory = await container.get('./index');
|
||||
// Execute the module factory - side effects fire registrations
|
||||
factory();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useEffect, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
import * as supersetCore from '@apache-superset/core';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
authentication,
|
||||
core,
|
||||
@@ -30,29 +31,17 @@ import {
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { RootState } from 'src/views/store';
|
||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||
import ExtensionsLoader from './ExtensionsLoader';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
commands: typeof commands;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
};
|
||||
}
|
||||
}
|
||||
import './types';
|
||||
|
||||
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ user }) => user.userId,
|
||||
@@ -92,6 +81,9 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
||||
'Error setting up extensions:',
|
||||
error,
|
||||
);
|
||||
dispatch(
|
||||
addWarningToast(t('Extensions failed to load: %s', String(error))),
|
||||
);
|
||||
}
|
||||
}
|
||||
setInitialized(true);
|
||||
|
||||
43
superset-frontend/src/extensions/types.ts
Normal file
43
superset-frontend/src/extensions/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import type {
|
||||
authentication,
|
||||
core,
|
||||
commands,
|
||||
editors,
|
||||
extensions,
|
||||
menus,
|
||||
sqlLab,
|
||||
views,
|
||||
} from 'src/core';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
superset: {
|
||||
authentication: typeof authentication;
|
||||
core: typeof core;
|
||||
commands: typeof commands;
|
||||
editors: typeof editors;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
sqlLab: typeof sqlLab;
|
||||
views: typeof views;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1148,6 +1148,33 @@ EXPLORE_FORM_DATA_CACHE_CONFIG: CacheConfig = {
|
||||
"CODEC": JsonKeyValueCodec(),
|
||||
}
|
||||
|
||||
# Extension Tier 2: Ephemeral State - Server-side cache with TTL.
|
||||
# Short-lived KV storage that automatically expires. Not guaranteed to
|
||||
# survive server restarts. Use for temporary state like job progress,
|
||||
# intermediate results, or cross-request state. Can be replaced by any
|
||||
# `Flask-Caching` backend (e.g. RedisCache for production).
|
||||
EXTENSIONS_EPHEMERAL_STORAGE: CacheConfig = {
|
||||
"CACHE_TYPE": "SupersetMetastoreCache",
|
||||
"CACHE_DEFAULT_TIMEOUT": int(timedelta(hours=1).total_seconds()),
|
||||
# Should the timeout be reset when retrieving a cached value?
|
||||
"REFRESH_TIMEOUT_ON_RETRIEVAL": False,
|
||||
# The following parameter only applies to `MetastoreCache`:
|
||||
# How should entries be serialized/deserialized?
|
||||
"CODEC": JsonKeyValueCodec(),
|
||||
}
|
||||
|
||||
# Extension Tier 3: Persistent State - Database storage.
|
||||
# Durable KV storage backed by a dedicated database table (`extension_storage`).
|
||||
# Survives server restarts, cache evictions, and browser clears.
|
||||
EXTENSIONS_PERSISTENT_STORAGE: dict[str, Any] = {
|
||||
# Maximum storage quota per extension in bytes (default: 100 MB)
|
||||
"QUOTA_PER_EXTENSION": 100 * 1024 * 1024,
|
||||
# Encryption keys for values stored at rest. Falls back to SECRET_KEY when
|
||||
# not set. Rotate keys by prepending the new key — all keys are tried on
|
||||
# decryption, enabling zero-downtime rotation.
|
||||
"ENCRYPTION_KEYS": [],
|
||||
}
|
||||
|
||||
# store cache keys by datasource UID (via CacheKey) for custom processing/invalidation
|
||||
STORE_CACHE_KEYS_IN_METADATA_DB = False
|
||||
|
||||
|
||||
@@ -34,9 +34,6 @@ if TYPE_CHECKING:
|
||||
from superset_core.rest_api.api import RestApi
|
||||
|
||||
|
||||
__all__ = ["initialize_core_api_dependencies"]
|
||||
|
||||
|
||||
def inject_dao_implementations() -> None:
|
||||
"""
|
||||
Replace abstract DAO classes in superset_core common/queries/tasks daos with
|
||||
@@ -229,6 +226,41 @@ def inject_model_session_implementation() -> None:
|
||||
core_models_module.get_session = get_session
|
||||
|
||||
|
||||
def inject_storage_implementations() -> None:
|
||||
"""
|
||||
Replace abstract storage functions in superset_core.extensions.storage with concrete
|
||||
implementations from Superset.
|
||||
"""
|
||||
import superset_core.extensions.storage.ephemeral_state as core_ephemeral_state
|
||||
import superset_core.extensions.storage.persistent_state as core_persistent_state
|
||||
|
||||
from superset.extensions.storage.ephemeral_state import EphemeralStateImpl
|
||||
from superset.extensions.storage.persistent_state_impl import PersistentStateImpl
|
||||
|
||||
# Replace abstract functions with concrete implementations
|
||||
core_ephemeral_state.get = EphemeralStateImpl.get
|
||||
core_ephemeral_state.set = EphemeralStateImpl.set
|
||||
core_ephemeral_state.remove = EphemeralStateImpl.remove
|
||||
core_ephemeral_state.shared = EphemeralStateImpl.shared
|
||||
|
||||
core_persistent_state.get = PersistentStateImpl.get
|
||||
core_persistent_state.set = PersistentStateImpl.set
|
||||
core_persistent_state.remove = PersistentStateImpl.remove
|
||||
core_persistent_state.shared = PersistentStateImpl.shared
|
||||
|
||||
|
||||
def inject_extension_context() -> None:
|
||||
"""
|
||||
Replace abstract get_context in superset_core.extensions.context
|
||||
with concrete implementation from Superset.
|
||||
"""
|
||||
import superset_core.extensions.context as core_context
|
||||
|
||||
from superset.extensions.context import get_context
|
||||
|
||||
core_context.get_context = get_context
|
||||
|
||||
|
||||
def initialize_core_api_dependencies() -> None:
|
||||
"""
|
||||
Initialize all dependency injections for the superset-core API.
|
||||
@@ -242,3 +274,5 @@ def initialize_core_api_dependencies() -> None:
|
||||
inject_query_implementations()
|
||||
inject_task_implementations()
|
||||
inject_rest_api_implementations()
|
||||
inject_storage_implementations()
|
||||
inject_extension_context()
|
||||
|
||||
@@ -16,75 +16,126 @@
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Extension Context Management - provides ambient context during extension loading.
|
||||
Extension Context Management - provides ambient context for extensions.
|
||||
|
||||
This module provides a thread-local context system that allows decorators to
|
||||
automatically detect whether they are being called in host or extension code
|
||||
during extension loading.
|
||||
This module provides a context system using Python's contextvars that allows
|
||||
extensions to access their context (metadata and scoped resources) via get_context().
|
||||
|
||||
The context is set during extension loading and when extension callbacks are invoked.
|
||||
Uses ContextVar for thread-safe and async-safe context management with automatic
|
||||
save/restore for nested contexts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from threading import local
|
||||
from typing import Any, Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Iterator
|
||||
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
# Thread-local storage for extension context
|
||||
_extension_context: local = local()
|
||||
|
||||
class ExtensionStorage:
|
||||
"""Extension storage with all available tiers."""
|
||||
|
||||
@property
|
||||
def ephemeral(self) -> Any:
|
||||
from superset.extensions.storage.ephemeral_state import EphemeralStateImpl
|
||||
|
||||
return EphemeralStateImpl
|
||||
|
||||
@property
|
||||
def persistent(self) -> Any:
|
||||
from superset.extensions.storage.persistent_state_impl import (
|
||||
PersistentStateImpl,
|
||||
)
|
||||
|
||||
return PersistentStateImpl
|
||||
|
||||
|
||||
class ExtensionContext:
|
||||
"""Manages ambient extension context during loading."""
|
||||
|
||||
def __init__(self, manifest: Manifest):
|
||||
self.manifest = manifest
|
||||
|
||||
def __enter__(self) -> "ExtensionContext":
|
||||
if getattr(_extension_context, "current", None) is not None:
|
||||
current_extension = _extension_context.current.manifest.id
|
||||
raise RuntimeError(
|
||||
f"Cannot initialize extension {self.manifest.id} while extension "
|
||||
f"{current_extension} is already being initialized. "
|
||||
f"Nested extension initialization is not supported."
|
||||
)
|
||||
|
||||
_extension_context.current = self
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
# Clear the current context
|
||||
_extension_context.current = None
|
||||
|
||||
|
||||
class ExtensionContextWrapper:
|
||||
"""Wrapper for extension context with extensible properties."""
|
||||
class ConcreteExtensionContext:
|
||||
"""Concrete implementation of ExtensionContext for the host."""
|
||||
|
||||
def __init__(self, manifest: Manifest):
|
||||
self._manifest = manifest
|
||||
self._storage = ExtensionStorage()
|
||||
|
||||
@property
|
||||
def extension(self) -> Manifest:
|
||||
"""Extension metadata (new API)."""
|
||||
return self._manifest
|
||||
|
||||
@property
|
||||
def manifest(self) -> Manifest:
|
||||
"""Get the extension manifest."""
|
||||
"""Extension manifest (for backward compatibility)."""
|
||||
return self._manifest
|
||||
|
||||
# Future: Add other context properties here
|
||||
# @property
|
||||
# def security_context(self) -> SecurityContext: ...
|
||||
# @property
|
||||
# def build_info(self) -> BuildInfo: ...
|
||||
@property
|
||||
def storage(self) -> ExtensionStorage:
|
||||
return self._storage
|
||||
|
||||
|
||||
def get_current_extension_context() -> ExtensionContextWrapper | None:
|
||||
"""Get the currently active extension context wrapper, or None if in host code."""
|
||||
if context := getattr(_extension_context, "current", None):
|
||||
return ExtensionContextWrapper(context.manifest)
|
||||
return None
|
||||
# Context variable for ambient extension context pattern.
|
||||
# Thread-safe and async-safe via Python's contextvars.
|
||||
_current_context: ContextVar[ConcreteExtensionContext | None] = ContextVar(
|
||||
"extension_context", default=None
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def extension_context(manifest: Manifest) -> Generator[None, None, None]:
|
||||
"""Context manager for setting extension context during loading."""
|
||||
with ExtensionContext(manifest):
|
||||
def get_context() -> ConcreteExtensionContext:
|
||||
"""
|
||||
Get the current extension's context.
|
||||
|
||||
This is the host implementation that replaces the stub in superset_core.
|
||||
|
||||
:returns: The current extension's context.
|
||||
:raises RuntimeError: If called outside of an extension context.
|
||||
"""
|
||||
context = _current_context.get()
|
||||
if context is None:
|
||||
raise RuntimeError(
|
||||
"get_context() must be called within an extension context. "
|
||||
"Ensure this code is being executed during extension loading or "
|
||||
"within an extension callback."
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
def get_current_extension_context() -> ConcreteExtensionContext | None:
|
||||
"""Get the currently active extension context, or None if in host code."""
|
||||
return _current_context.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def use_context(ctx: ConcreteExtensionContext) -> Iterator[None]:
|
||||
"""
|
||||
Context manager to set ambient context for extension execution.
|
||||
|
||||
Used to establish the ambient context before executing extension code.
|
||||
The context is automatically restored after execution, supporting nested
|
||||
context switches.
|
||||
|
||||
:param ctx: ExtensionContext to set as the current context
|
||||
:yields: None
|
||||
"""
|
||||
token = _current_context.set(ctx)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_current_context.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def extension_context(manifest: Manifest) -> Iterator[ConcreteExtensionContext]:
|
||||
"""
|
||||
Context manager for setting extension context during loading.
|
||||
|
||||
Creates a new ExtensionContext for the given manifest and sets it as
|
||||
the current context. Supports nested contexts via ContextVar tokens.
|
||||
|
||||
:param manifest: The extension manifest
|
||||
:yields: The created ExtensionContext
|
||||
"""
|
||||
ctx = ConcreteExtensionContext(manifest)
|
||||
with use_context(ctx):
|
||||
yield ctx
|
||||
|
||||
28
superset/extensions/storage/__init__.py
Normal file
28
superset/extensions/storage/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Host implementations for extension storage APIs.
|
||||
|
||||
This module provides concrete implementations that are injected into
|
||||
superset_core.extensions.storage at startup.
|
||||
"""
|
||||
|
||||
from superset.extensions.storage.ephemeral_state import ( # noqa: F401
|
||||
EphemeralStateImpl,
|
||||
SharedEphemeralStateAccessor,
|
||||
)
|
||||
478
superset/extensions/storage/api.py
Normal file
478
superset/extensions/storage/api.py
Normal file
@@ -0,0 +1,478 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
REST API for extension storage.
|
||||
|
||||
Provides HTTP endpoints for frontend extensions to access server-side
|
||||
ephemeral storage without direct backend code.
|
||||
|
||||
All operations are user-scoped by default. Use `?shared=true` query param
|
||||
to access shared state visible to all users.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import g, request
|
||||
from flask.wrappers import Response
|
||||
from flask_appbuilder.api import BaseApi, expose, protect, safe
|
||||
|
||||
from superset.extensions import cache_manager
|
||||
from superset.extensions.storage.persistent_state_dao import ExtensionStorageDAO
|
||||
from superset.extensions.types import LoadedExtension
|
||||
from superset.extensions.utils import get_extensions
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
# Key separator
|
||||
SEPARATOR = ":"
|
||||
|
||||
# Key prefix for extension ephemeral state
|
||||
KEY_PREFIX = "superset-ext"
|
||||
|
||||
|
||||
def _build_cache_key(*parts: Any) -> str:
|
||||
"""Build a namespaced cache key from parts."""
|
||||
return SEPARATOR.join(str(part) for part in parts)
|
||||
|
||||
|
||||
def _get_extension_or_404(extension_id: str) -> LoadedExtension | None:
|
||||
"""Get extension by ID or return None if not found."""
|
||||
extensions = get_extensions()
|
||||
return extensions.get(extension_id)
|
||||
|
||||
|
||||
def _parse_ttl(body: dict[str, Any]) -> tuple[int | None, str | None]:
|
||||
"""Parse and validate TTL from request body.
|
||||
|
||||
Returns:
|
||||
(ttl, error_message) - ttl is None when omitted (cache uses
|
||||
CACHE_DEFAULT_TIMEOUT), error_message is set if the value is invalid.
|
||||
"""
|
||||
if "ttl" not in body:
|
||||
return None, None
|
||||
try:
|
||||
ttl = int(body["ttl"])
|
||||
except (TypeError, ValueError):
|
||||
return None, "Field 'ttl' must be a positive integer"
|
||||
if ttl <= 0:
|
||||
return None, "Field 'ttl' must be a positive integer"
|
||||
return ttl, None
|
||||
|
||||
|
||||
def _build_storage_key(extension_id: str, key: str, shared: bool) -> str:
|
||||
"""Build the cache key based on scope (user or shared)."""
|
||||
if shared:
|
||||
return _build_cache_key(KEY_PREFIX, extension_id, "shared", key)
|
||||
user_id = g.user.id
|
||||
return _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key)
|
||||
|
||||
|
||||
class ExtensionStorageRestApi(BaseApi):
|
||||
"""REST API for extension ephemeral state storage."""
|
||||
|
||||
allow_browser_login = True
|
||||
route_base = "/api/v1/extensions"
|
||||
|
||||
def response(self, status_code: int, **kwargs: Any) -> Response:
|
||||
"""Helper method to create JSON responses."""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify(kwargs), status_code
|
||||
|
||||
def response_404(self, message: str = "Not found") -> Response:
|
||||
"""Helper method to create 404 responses."""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify({"message": message}), 404
|
||||
|
||||
def response_400(self, message: str) -> Response:
|
||||
"""Helper method to create 400 responses."""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify({"message": message}), 400
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/storage/ephemeral/<key>", methods=("GET",))
|
||||
def get_ephemeral(
|
||||
self, publisher: str, name: str, key: str, **kwargs: Any
|
||||
) -> Response:
|
||||
"""Get a value from ephemeral state.
|
||||
---
|
||||
get:
|
||||
summary: Get a value from ephemeral state
|
||||
parameters:
|
||||
- in: path
|
||||
name: publisher
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension publisher
|
||||
- in: path
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension name
|
||||
- in: path
|
||||
name: key
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Storage key
|
||||
- in: query
|
||||
name: shared
|
||||
schema:
|
||||
type: boolean
|
||||
required: false
|
||||
description: If true, read from shared state visible to all users
|
||||
responses:
|
||||
200:
|
||||
description: Value retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
description: The stored value
|
||||
404:
|
||||
description: Extension not found
|
||||
"""
|
||||
extension_id = f"{publisher}.{name}"
|
||||
extension = _get_extension_or_404(extension_id)
|
||||
if not extension:
|
||||
return self.response_404("Extension not found")
|
||||
|
||||
shared = request.args.get("shared", "false").lower() == "true"
|
||||
cache_key = _build_storage_key(extension_id, key, shared)
|
||||
value = cache_manager.extension_ephemeral_state_cache.get(cache_key)
|
||||
|
||||
return self.response(200, result=value)
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/storage/ephemeral/<key>", methods=("PUT",))
|
||||
def set_ephemeral(
|
||||
self, publisher: str, name: str, key: str, **kwargs: Any
|
||||
) -> Response:
|
||||
"""Set a value in ephemeral state.
|
||||
---
|
||||
put:
|
||||
summary: Set a value in ephemeral state
|
||||
parameters:
|
||||
- in: path
|
||||
name: publisher
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension publisher
|
||||
- in: path
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension name
|
||||
- in: path
|
||||
name: key
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Storage key
|
||||
- in: query
|
||||
name: shared
|
||||
schema:
|
||||
type: boolean
|
||||
required: false
|
||||
description: If true, store as shared state visible to all users
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
description: The value to store
|
||||
ttl:
|
||||
type: integer
|
||||
description: Time-to-live in seconds (defaults to
|
||||
CACHE_DEFAULT_TIMEOUT)
|
||||
responses:
|
||||
200:
|
||||
description: Value stored successfully
|
||||
400:
|
||||
description: Invalid request body
|
||||
404:
|
||||
description: Extension not found
|
||||
"""
|
||||
extension_id = f"{publisher}.{name}"
|
||||
extension = _get_extension_or_404(extension_id)
|
||||
if not extension:
|
||||
return self.response_404("Extension not found")
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
if "value" not in body:
|
||||
return self.response_400("Request body must contain 'value' field")
|
||||
|
||||
value = body["value"]
|
||||
ttl, error = _parse_ttl(body)
|
||||
if error:
|
||||
return self.response_400(error)
|
||||
|
||||
shared = request.args.get("shared", "false").lower() == "true"
|
||||
cache_key = _build_storage_key(extension_id, key, shared)
|
||||
cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl)
|
||||
|
||||
return self.response(200, message="Value stored successfully")
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/storage/ephemeral/<key>", methods=("DELETE",))
|
||||
def delete_ephemeral(
|
||||
self, publisher: str, name: str, key: str, **kwargs: Any
|
||||
) -> Response:
|
||||
"""Delete a value from ephemeral state.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a value from ephemeral state
|
||||
parameters:
|
||||
- in: path
|
||||
name: publisher
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension publisher
|
||||
- in: path
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension name
|
||||
- in: path
|
||||
name: key
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Storage key
|
||||
- in: query
|
||||
name: shared
|
||||
schema:
|
||||
type: boolean
|
||||
required: false
|
||||
description: If true, delete from shared state
|
||||
responses:
|
||||
200:
|
||||
description: Value deleted successfully
|
||||
404:
|
||||
description: Extension not found
|
||||
"""
|
||||
extension_id = f"{publisher}.{name}"
|
||||
extension = _get_extension_or_404(extension_id)
|
||||
if not extension:
|
||||
return self.response_404("Extension not found")
|
||||
|
||||
shared = request.args.get("shared", "false").lower() == "true"
|
||||
cache_key = _build_storage_key(extension_id, key, shared)
|
||||
cache_manager.extension_ephemeral_state_cache.delete(cache_key)
|
||||
|
||||
return self.response(200, message="Value deleted successfully")
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/storage/persistent/<key>", methods=("GET",))
|
||||
def get_persistent(
|
||||
self, publisher: str, name: str, key: str, **kwargs: Any
|
||||
) -> Response:
|
||||
"""Get a value from persistent state.
|
||||
---
|
||||
get:
|
||||
summary: Get a value from persistent state
|
||||
parameters:
|
||||
- in: path
|
||||
name: publisher
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension publisher
|
||||
- in: path
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension name
|
||||
- in: path
|
||||
name: key
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Storage key
|
||||
- in: query
|
||||
name: shared
|
||||
schema:
|
||||
type: boolean
|
||||
required: false
|
||||
description: If true, read from shared state visible to all users
|
||||
responses:
|
||||
200:
|
||||
description: Value retrieved successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
description: The stored value
|
||||
404:
|
||||
description: Extension not found
|
||||
"""
|
||||
extension_id = f"{publisher}.{name}"
|
||||
extension = _get_extension_or_404(extension_id)
|
||||
if not extension:
|
||||
return self.response_404("Extension not found")
|
||||
|
||||
shared = request.args.get("shared", "false").lower() == "true"
|
||||
user_fk = None if shared else g.user.id
|
||||
raw = ExtensionStorageDAO.get_value(extension_id, key, user_fk=user_fk)
|
||||
value = json.loads(raw) if raw is not None else None
|
||||
|
||||
return self.response(200, result=value)
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/storage/persistent/<key>", methods=("PUT",))
|
||||
@transaction()
|
||||
def set_persistent(
|
||||
self, publisher: str, name: str, key: str, **kwargs: Any
|
||||
) -> Response:
|
||||
"""Set a value in persistent state.
|
||||
---
|
||||
put:
|
||||
summary: Set a value in persistent state
|
||||
parameters:
|
||||
- in: path
|
||||
name: publisher
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension publisher
|
||||
- in: path
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension name
|
||||
- in: path
|
||||
name: key
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Storage key
|
||||
- in: query
|
||||
name: shared
|
||||
schema:
|
||||
type: boolean
|
||||
required: false
|
||||
description: If true, store as shared state visible to all users
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
value:
|
||||
description: The value to store (must be JSON-serializable)
|
||||
responses:
|
||||
200:
|
||||
description: Value stored successfully
|
||||
400:
|
||||
description: Invalid request body
|
||||
404:
|
||||
description: Extension not found
|
||||
"""
|
||||
extension_id = f"{publisher}.{name}"
|
||||
extension = _get_extension_or_404(extension_id)
|
||||
if not extension:
|
||||
return self.response_404("Extension not found")
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
if "value" not in body:
|
||||
return self.response_400("Request body must contain 'value' field")
|
||||
|
||||
shared = request.args.get("shared", "false").lower() == "true"
|
||||
user_fk = None if shared else g.user.id
|
||||
value_bytes = json.dumps(body["value"]).encode()
|
||||
ExtensionStorageDAO.set(extension_id, key, value_bytes, user_fk=user_fk)
|
||||
|
||||
return self.response(200, message="Value stored successfully")
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/<publisher>/<name>/storage/persistent/<key>", methods=("DELETE",))
|
||||
@transaction()
|
||||
def delete_persistent(
|
||||
self, publisher: str, name: str, key: str, **kwargs: Any
|
||||
) -> Response:
|
||||
"""Delete a value from persistent state.
|
||||
---
|
||||
delete:
|
||||
summary: Delete a value from persistent state
|
||||
parameters:
|
||||
- in: path
|
||||
name: publisher
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension publisher
|
||||
- in: path
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Extension name
|
||||
- in: path
|
||||
name: key
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
description: Storage key
|
||||
- in: query
|
||||
name: shared
|
||||
schema:
|
||||
type: boolean
|
||||
required: false
|
||||
description: If true, delete from shared state
|
||||
responses:
|
||||
200:
|
||||
description: Value deleted successfully
|
||||
404:
|
||||
description: Extension not found
|
||||
"""
|
||||
extension_id = f"{publisher}.{name}"
|
||||
extension = _get_extension_or_404(extension_id)
|
||||
if not extension:
|
||||
return self.response_404("Extension not found")
|
||||
|
||||
shared = request.args.get("shared", "false").lower() == "true"
|
||||
user_fk = None if shared else g.user.id
|
||||
ExtensionStorageDAO.delete(extension_id, key, user_fk=user_fk)
|
||||
|
||||
return self.response(200, message="Value deleted successfully")
|
||||
171
superset/extensions/storage/ephemeral_state.py
Normal file
171
superset/extensions/storage/ephemeral_state.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Host implementation for Ephemeral State (Tier 2 Storage).
|
||||
|
||||
Provides the concrete cache-backed implementation that is injected into
|
||||
superset_core.extensions.storage.ephemeral_state at startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import g
|
||||
|
||||
from superset.extensions import cache_manager
|
||||
from superset.extensions.context import get_current_extension_context
|
||||
|
||||
# Key separator
|
||||
SEPARATOR = ":"
|
||||
|
||||
# Key prefix for extension ephemeral state
|
||||
KEY_PREFIX = "superset-ext"
|
||||
|
||||
|
||||
def _get_extension_id() -> str:
|
||||
"""Get the current extension ID from context."""
|
||||
context = get_current_extension_context()
|
||||
if context is None:
|
||||
raise RuntimeError(
|
||||
"ephemeral_state can only be used within an extension context. "
|
||||
"Ensure this code is being executed during extension loading or "
|
||||
"within an extension API request handler."
|
||||
)
|
||||
return context.manifest.id
|
||||
|
||||
|
||||
def _get_current_user_id() -> int:
|
||||
"""Get the current authenticated user's ID."""
|
||||
user = getattr(g, "user", None)
|
||||
if user is None or not hasattr(user, "id"):
|
||||
raise RuntimeError(
|
||||
"ephemeral_state requires an authenticated user. "
|
||||
"Ensure the request has been authenticated."
|
||||
)
|
||||
return user.id
|
||||
|
||||
|
||||
def _build_cache_key(*parts: Any) -> str:
|
||||
"""Build a namespaced cache key from parts."""
|
||||
return SEPARATOR.join(str(part) for part in parts)
|
||||
|
||||
|
||||
class SharedEphemeralStateAccessor:
|
||||
"""
|
||||
Accessor for shared (global) ephemeral state.
|
||||
|
||||
Data stored via this accessor is visible to all users of the extension.
|
||||
Extension ID is resolved lazily on each operation from the current context.
|
||||
"""
|
||||
|
||||
def _build_key(self, key: str) -> str:
|
||||
"""Build a shared (global) cache key."""
|
||||
extension_id = _get_extension_id()
|
||||
return _build_cache_key(KEY_PREFIX, extension_id, "shared", key)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""
|
||||
Get a value from shared ephemeral state.
|
||||
|
||||
:param key: The key to retrieve.
|
||||
:returns: The stored value, or None if not found or expired.
|
||||
"""
|
||||
cache_key = self._build_key(key)
|
||||
return cache_manager.extension_ephemeral_state_cache.get(cache_key)
|
||||
|
||||
def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
||||
"""
|
||||
Set a value in shared ephemeral state with TTL.
|
||||
|
||||
:param key: The key to store.
|
||||
:param value: The value to store (must be JSON-serializable).
|
||||
:param ttl: Time-to-live in seconds. Defaults to CACHE_DEFAULT_TIMEOUT.
|
||||
"""
|
||||
cache_key = self._build_key(key)
|
||||
cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl)
|
||||
|
||||
def remove(self, key: str) -> None:
|
||||
"""
|
||||
Remove a value from shared ephemeral state.
|
||||
|
||||
:param key: The key to remove.
|
||||
"""
|
||||
cache_key = self._build_key(key)
|
||||
cache_manager.extension_ephemeral_state_cache.delete(cache_key)
|
||||
|
||||
|
||||
class EphemeralStateImpl:
|
||||
"""
|
||||
Host implementation for ephemeral state operations.
|
||||
|
||||
This class provides the concrete implementation that is injected into
|
||||
superset_core.extensions.storage.ephemeral_state.
|
||||
|
||||
By default, all operations are user-scoped (private to the current user).
|
||||
Use `shared` to access state that is visible to all users.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _build_user_key(extension_id: str, user_id: int, key: str) -> str:
|
||||
"""Build a user-scoped cache key."""
|
||||
return _build_cache_key(KEY_PREFIX, extension_id, "user", user_id, key)
|
||||
|
||||
@staticmethod
|
||||
def get(key: str) -> Any:
|
||||
"""
|
||||
Get a value from user-scoped ephemeral state.
|
||||
|
||||
:param key: The key to retrieve.
|
||||
:returns: The stored value, or None if not found or expired.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
user_id = _get_current_user_id()
|
||||
cache_key = EphemeralStateImpl._build_user_key(extension_id, user_id, key)
|
||||
return cache_manager.extension_ephemeral_state_cache.get(cache_key)
|
||||
|
||||
@staticmethod
|
||||
def set(key: str, value: Any, ttl: int | None = None) -> None:
|
||||
"""
|
||||
Set a value in user-scoped ephemeral state with TTL.
|
||||
|
||||
:param key: The key to store.
|
||||
:param value: The value to store (must be JSON-serializable).
|
||||
:param ttl: Time-to-live in seconds. Defaults to CACHE_DEFAULT_TIMEOUT.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
user_id = _get_current_user_id()
|
||||
cache_key = EphemeralStateImpl._build_user_key(extension_id, user_id, key)
|
||||
cache_manager.extension_ephemeral_state_cache.set(cache_key, value, timeout=ttl)
|
||||
|
||||
@staticmethod
|
||||
def remove(key: str) -> None:
|
||||
"""
|
||||
Remove a value from user-scoped ephemeral state.
|
||||
|
||||
:param key: The key to remove.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
user_id = _get_current_user_id()
|
||||
cache_key = EphemeralStateImpl._build_user_key(extension_id, user_id, key)
|
||||
cache_manager.extension_ephemeral_state_cache.delete(cache_key)
|
||||
|
||||
#: Shared (global) ephemeral state accessor.
|
||||
#: Data stored via this accessor is visible to all users of the extension.
|
||||
#: WARNING: Do not store user-specific or sensitive data here.
|
||||
shared: SharedEphemeralStateAccessor = SharedEphemeralStateAccessor()
|
||||
278
superset/extensions/storage/persistent_state_dao.py
Normal file
278
superset/extensions/storage/persistent_state_dao.py
Normal file
@@ -0,0 +1,278 @@
|
||||
# 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 base64
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
|
||||
from flask import current_app
|
||||
from sqlalchemy import and_
|
||||
|
||||
from superset import db
|
||||
from superset.extensions.storage.persistent_state_model import ExtensionStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _key_to_fernet(raw_key: str | bytes) -> Fernet:
|
||||
"""Derive a Fernet instance from an arbitrary-length secret string.
|
||||
|
||||
SHA-256 compresses the key to exactly 32 bytes, which are then
|
||||
base64url-encoded to satisfy Fernet's key format requirement.
|
||||
"""
|
||||
if isinstance(raw_key, str):
|
||||
raw_key = raw_key.encode()
|
||||
return Fernet(base64.urlsafe_b64encode(hashlib.sha256(raw_key).digest()))
|
||||
|
||||
|
||||
def _fernet() -> MultiFernet:
|
||||
"""Return a MultiFernet built from EXTENSIONS_PERSISTENT_STORAGE["ENCRYPTION_KEYS"].
|
||||
|
||||
Falls back to SECRET_KEY when the list is absent or empty. The first key in
|
||||
the list is used for new encryptions; all keys are tried on decryption,
|
||||
enabling zero-downtime rotation: add the new key at the front of
|
||||
ENCRYPTION_KEYS, then run ``superset rotate-extension-storage-keys`` to
|
||||
re-encrypt every row with the new key.
|
||||
"""
|
||||
persistent_cfg = current_app.config.get("EXTENSIONS_PERSISTENT_STORAGE", {})
|
||||
raw_keys: list[str | bytes] = persistent_cfg.get("ENCRYPTION_KEYS") or [
|
||||
current_app.config["SECRET_KEY"]
|
||||
]
|
||||
return MultiFernet([_key_to_fernet(k) for k in raw_keys])
|
||||
|
||||
|
||||
def _scope_filter(
|
||||
extension_id: str,
|
||||
key: str,
|
||||
user_fk: int | None = None,
|
||||
resource_type: str | None = None,
|
||||
resource_uuid: str | None = None,
|
||||
) -> list[object]:
|
||||
"""Build the SQLAlchemy filter list for a scoped lookup."""
|
||||
filters: list[object] = [
|
||||
ExtensionStorage.extension_id == extension_id,
|
||||
ExtensionStorage.key == key,
|
||||
]
|
||||
if user_fk is None:
|
||||
filters.append(ExtensionStorage.user_fk.is_(None))
|
||||
else:
|
||||
filters.append(ExtensionStorage.user_fk == user_fk)
|
||||
if resource_type is None:
|
||||
filters.append(ExtensionStorage.resource_type.is_(None))
|
||||
else:
|
||||
filters.append(ExtensionStorage.resource_type == resource_type)
|
||||
if resource_uuid is None:
|
||||
filters.append(ExtensionStorage.resource_uuid.is_(None))
|
||||
else:
|
||||
filters.append(ExtensionStorage.resource_uuid == resource_uuid)
|
||||
return filters
|
||||
|
||||
|
||||
class ExtensionStorageDAO:
|
||||
"""Persistent key-value store for extensions.
|
||||
|
||||
Provides scoped get/set/delete and list operations covering the three
|
||||
storage scopes defined by the Tier 3 proposal:
|
||||
|
||||
* Global scope — user_fk=None, resource_type=None
|
||||
* User scope — user_fk=<id>, resource_type=None
|
||||
* Resource scope — resource_type + resource_uuid set
|
||||
"""
|
||||
|
||||
# ── Read ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def get(
|
||||
extension_id: str,
|
||||
key: str,
|
||||
user_fk: int | None = None,
|
||||
resource_type: str | None = None,
|
||||
resource_uuid: str | None = None,
|
||||
) -> ExtensionStorage | None:
|
||||
"""Return the raw storage entry. The value field may be encrypted."""
|
||||
entry = (
|
||||
db.session.query(ExtensionStorage)
|
||||
.filter(
|
||||
and_(
|
||||
*_scope_filter(
|
||||
extension_id, key, user_fk, resource_type, resource_uuid
|
||||
)
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
def get_value(
|
||||
extension_id: str,
|
||||
key: str,
|
||||
user_fk: int | None = None,
|
||||
resource_type: str | None = None,
|
||||
resource_uuid: str | None = None,
|
||||
) -> bytes | None:
|
||||
"""Return the raw (decrypted) value bytes, or None if not found."""
|
||||
entry = ExtensionStorageDAO.get(
|
||||
extension_id, key, user_fk, resource_type, resource_uuid
|
||||
)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry.is_encrypted:
|
||||
try:
|
||||
return _fernet().decrypt(entry.value)
|
||||
except InvalidToken:
|
||||
logger.error(
|
||||
"Failed to decrypt extension storage value for "
|
||||
"extension_id=%s key=%s — possible key rotation issue",
|
||||
extension_id,
|
||||
key,
|
||||
)
|
||||
return None
|
||||
return entry.value
|
||||
|
||||
# ── Write (upsert) ────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def set(
|
||||
extension_id: str,
|
||||
key: str,
|
||||
value: bytes,
|
||||
value_type: str = "application/json",
|
||||
user_fk: int | None = None,
|
||||
resource_type: str | None = None,
|
||||
resource_uuid: str | None = None,
|
||||
category: str | None = None,
|
||||
description: str | None = None,
|
||||
is_encrypted: bool = False,
|
||||
) -> ExtensionStorage:
|
||||
"""Upsert a storage entry. Encrypts value when is_encrypted=True."""
|
||||
stored_value = _fernet().encrypt(value) if is_encrypted else value
|
||||
|
||||
entry = (
|
||||
db.session.query(ExtensionStorage)
|
||||
.filter(
|
||||
and_(
|
||||
*_scope_filter(
|
||||
extension_id, key, user_fk, resource_type, resource_uuid
|
||||
)
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if entry is not None:
|
||||
entry.value = stored_value
|
||||
entry.value_type = value_type
|
||||
entry.category = category
|
||||
entry.description = description
|
||||
entry.is_encrypted = is_encrypted
|
||||
else:
|
||||
entry = ExtensionStorage(
|
||||
extension_id=extension_id,
|
||||
key=key,
|
||||
value=stored_value,
|
||||
value_type=value_type,
|
||||
user_fk=user_fk,
|
||||
resource_type=resource_type,
|
||||
resource_uuid=resource_uuid,
|
||||
category=category,
|
||||
description=description,
|
||||
is_encrypted=is_encrypted,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.flush()
|
||||
return entry
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def delete(
|
||||
extension_id: str,
|
||||
key: str,
|
||||
user_fk: int | None = None,
|
||||
resource_type: str | None = None,
|
||||
resource_uuid: str | None = None,
|
||||
) -> bool:
|
||||
"""Delete an entry. Returns True if a row was removed."""
|
||||
entry = (
|
||||
db.session.query(ExtensionStorage)
|
||||
.filter(
|
||||
and_(
|
||||
*_scope_filter(
|
||||
extension_id, key, user_fk, resource_type, resource_uuid
|
||||
)
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if entry is None:
|
||||
return False
|
||||
db.session.delete(entry)
|
||||
db.session.flush()
|
||||
return True
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def list_global(
|
||||
extension_id: str,
|
||||
category: str | None = None,
|
||||
) -> list[ExtensionStorage]:
|
||||
"""List all global (user_fk=NULL, resource_type=NULL) entries."""
|
||||
q = db.session.query(ExtensionStorage).filter(
|
||||
ExtensionStorage.extension_id == extension_id,
|
||||
ExtensionStorage.user_fk.is_(None),
|
||||
ExtensionStorage.resource_type.is_(None),
|
||||
)
|
||||
if category is not None:
|
||||
q = q.filter(ExtensionStorage.category == category)
|
||||
return q.order_by(ExtensionStorage.key).all()
|
||||
|
||||
@staticmethod
|
||||
def list_user(
|
||||
extension_id: str,
|
||||
user_fk: int,
|
||||
category: str | None = None,
|
||||
) -> list[ExtensionStorage]:
|
||||
"""List all user-scoped entries (resource_type=NULL)."""
|
||||
q = db.session.query(ExtensionStorage).filter(
|
||||
ExtensionStorage.extension_id == extension_id,
|
||||
ExtensionStorage.user_fk == user_fk,
|
||||
ExtensionStorage.resource_type.is_(None),
|
||||
)
|
||||
if category is not None:
|
||||
q = q.filter(ExtensionStorage.category == category)
|
||||
return q.order_by(ExtensionStorage.key).all()
|
||||
|
||||
@staticmethod
|
||||
def list_resource(
|
||||
extension_id: str,
|
||||
resource_type: str,
|
||||
resource_uuid: str,
|
||||
category: str | None = None,
|
||||
) -> list[ExtensionStorage]:
|
||||
"""List all entries linked to a specific resource."""
|
||||
q = db.session.query(ExtensionStorage).filter(
|
||||
ExtensionStorage.extension_id == extension_id,
|
||||
ExtensionStorage.resource_type == resource_type,
|
||||
ExtensionStorage.resource_uuid == resource_uuid,
|
||||
)
|
||||
if category is not None:
|
||||
q = q.filter(ExtensionStorage.category == category)
|
||||
return q.order_by(ExtensionStorage.key).all()
|
||||
165
superset/extensions/storage/persistent_state_impl.py
Normal file
165
superset/extensions/storage/persistent_state_impl.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Host implementation for Persistent State (Tier 3 Storage).
|
||||
|
||||
Provides the concrete database-backed implementation that is injected into
|
||||
superset_core.extensions.storage.persistent_state at startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import g
|
||||
|
||||
from superset.extensions.context import get_current_extension_context
|
||||
from superset.extensions.storage.persistent_state_dao import ExtensionStorageDAO
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
|
||||
def _get_extension_id() -> str:
|
||||
"""Get the current extension ID from context."""
|
||||
context = get_current_extension_context()
|
||||
if context is None:
|
||||
raise RuntimeError(
|
||||
"persistent_state can only be used within an extension context. "
|
||||
"Ensure this code is being executed during extension loading or "
|
||||
"within an extension API request handler."
|
||||
)
|
||||
return context.manifest.id
|
||||
|
||||
|
||||
def _get_current_user_id() -> int:
|
||||
"""Get the current authenticated user's ID."""
|
||||
user = getattr(g, "user", None)
|
||||
if user is None or not hasattr(user, "id"):
|
||||
raise RuntimeError(
|
||||
"persistent_state requires an authenticated user. "
|
||||
"Ensure the request has been authenticated."
|
||||
)
|
||||
return user.id
|
||||
|
||||
|
||||
def _decode(raw: bytes | None) -> Any:
|
||||
"""Decode stored bytes back to a Python value."""
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _encode(value: Any) -> bytes:
|
||||
"""Encode a Python value for database storage."""
|
||||
return json.dumps(value).encode()
|
||||
|
||||
|
||||
class SharedPersistentStateAccessor:
|
||||
"""
|
||||
Accessor for shared (global) persistent state.
|
||||
|
||||
Data stored via this accessor is visible to all users of the extension.
|
||||
Extension ID is resolved lazily on each operation from the current context.
|
||||
"""
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
"""
|
||||
Get a value from shared persistent state.
|
||||
|
||||
:param key: The key to retrieve.
|
||||
:returns: The stored value, or None if not found.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
raw = ExtensionStorageDAO.get_value(extension_id, key, user_fk=None)
|
||||
return _decode(raw)
|
||||
|
||||
@transaction()
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a value in shared persistent state.
|
||||
|
||||
:param key: The key to store.
|
||||
:param value: The value to store (must be JSON-serializable).
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
ExtensionStorageDAO.set(extension_id, key, _encode(value), user_fk=None)
|
||||
|
||||
@transaction()
|
||||
def remove(self, key: str) -> None:
|
||||
"""
|
||||
Remove a value from shared persistent state.
|
||||
|
||||
:param key: The key to remove.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
ExtensionStorageDAO.delete(extension_id, key, user_fk=None)
|
||||
|
||||
|
||||
class PersistentStateImpl:
|
||||
"""
|
||||
Host implementation for persistent state operations.
|
||||
|
||||
This class provides the concrete implementation that is injected into
|
||||
superset_core.extensions.storage.persistent_state.
|
||||
|
||||
By default, all operations are user-scoped (private to the current user).
|
||||
Use `shared` to access state that is visible to all users.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get(key: str) -> Any:
|
||||
"""
|
||||
Get a value from user-scoped persistent state.
|
||||
|
||||
:param key: The key to retrieve.
|
||||
:returns: The stored value, or None if not found.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
user_id = _get_current_user_id()
|
||||
raw = ExtensionStorageDAO.get_value(extension_id, key, user_fk=user_id)
|
||||
return _decode(raw)
|
||||
|
||||
@staticmethod
|
||||
@transaction()
|
||||
def set(key: str, value: Any) -> None:
|
||||
"""
|
||||
Set a value in user-scoped persistent state.
|
||||
|
||||
:param key: The key to store.
|
||||
:param value: The value to store (must be JSON-serializable).
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
user_id = _get_current_user_id()
|
||||
ExtensionStorageDAO.set(extension_id, key, _encode(value), user_fk=user_id)
|
||||
|
||||
@staticmethod
|
||||
@transaction()
|
||||
def remove(key: str) -> None:
|
||||
"""
|
||||
Remove a value from user-scoped persistent state.
|
||||
|
||||
:param key: The key to remove.
|
||||
"""
|
||||
extension_id = _get_extension_id()
|
||||
user_id = _get_current_user_id()
|
||||
ExtensionStorageDAO.delete(extension_id, key, user_fk=user_id)
|
||||
|
||||
#: Shared (global) persistent state accessor.
|
||||
#: Data stored via this accessor is visible to all users of the extension.
|
||||
#: WARNING: Do not store user-specific or sensitive data here.
|
||||
shared: SharedPersistentStateAccessor = SharedPersistentStateAccessor()
|
||||
126
superset/extensions/storage/persistent_state_model.py
Normal file
126
superset/extensions/storage/persistent_state_model.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# 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.
|
||||
|
||||
import uuid as uuid_module
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import backref, relationship
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
from superset.models.helpers import AuditMixinNullable
|
||||
|
||||
# 16 MB — matches the KeyValue store limit.
|
||||
EXTENSION_STORAGE_MAX_SIZE = 2**24 - 1
|
||||
|
||||
|
||||
class ExtensionStorage(AuditMixinNullable, Model):
|
||||
"""Generic persistent key-value storage for extensions (Tier 3).
|
||||
|
||||
Each row is identified by (extension_id, user_fk, resource_type,
|
||||
resource_uuid, key):
|
||||
|
||||
* Global scope — user_fk IS NULL, resource_type IS NULL
|
||||
* User scope — user_fk set, resource_type IS NULL
|
||||
* Resource scope — resource_type + resource_uuid set (user_fk optional)
|
||||
|
||||
The payload is stored as raw bytes (value) with a MIME-type hint
|
||||
(value_type). When is_encrypted is True the value has been encrypted
|
||||
at the DAO layer using Fernet and must be decrypted before use.
|
||||
"""
|
||||
|
||||
__tablename__ = "extension_storage"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
uuid = Column(
|
||||
UUIDType(binary=True),
|
||||
default=uuid_module.uuid4,
|
||||
unique=True,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Extension identity
|
||||
extension_id = Column(String(255), nullable=False)
|
||||
|
||||
# Scope discriminators — all nullable; NULLs define the scope (see docstring)
|
||||
user_fk = Column(
|
||||
Integer,
|
||||
ForeignKey(
|
||||
"ab_user.id",
|
||||
ondelete="SET NULL",
|
||||
name="fk_extension_storage_user_fk_ab_user",
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
resource_type = Column(String(64), nullable=True)
|
||||
resource_uuid = Column(String(36), nullable=True)
|
||||
|
||||
# Storage key within the scope
|
||||
key = Column(String(255), nullable=False)
|
||||
|
||||
# Optional metadata
|
||||
category = Column(String(64), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Payload
|
||||
value = Column(LargeBinary(EXTENSION_STORAGE_MAX_SIZE), nullable=False)
|
||||
value_type = Column(String(255), nullable=False, default="application/json")
|
||||
is_encrypted = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
user = relationship(
|
||||
"User",
|
||||
backref=backref("extension_storage_entries", cascade="all, delete-orphan"),
|
||||
foreign_keys=[user_fk],
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Unique constraint prevents duplicate rows from concurrent writes
|
||||
UniqueConstraint(
|
||||
"extension_id",
|
||||
"user_fk",
|
||||
"resource_type",
|
||||
"resource_uuid",
|
||||
"key",
|
||||
name="uq_extension_storage_scoped_key",
|
||||
),
|
||||
# Composite index covering all lookup dimensions
|
||||
Index(
|
||||
"ix_ext_storage_lookup",
|
||||
"extension_id",
|
||||
"user_fk",
|
||||
"resource_type",
|
||||
"resource_uuid",
|
||||
"key",
|
||||
),
|
||||
Index("ix_ext_storage_extension_id", "extension_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<ExtensionStorage {self.extension_id}/"
|
||||
f"user={self.user_fk}/res={self.resource_type}/{self.key}>"
|
||||
)
|
||||
@@ -275,8 +275,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
|
||||
if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"):
|
||||
from superset.extensions.api import ExtensionsRestApi
|
||||
from superset.extensions.storage.api import ExtensionStorageRestApi
|
||||
|
||||
appbuilder.add_api(ExtensionsRestApi)
|
||||
appbuilder.add_api(ExtensionStorageRestApi)
|
||||
|
||||
if feature_flag_manager.is_feature_enabled("GLOBAL_TASK_FRAMEWORK"):
|
||||
from superset.tasks.api import TaskRestApi
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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_extension_storage_table
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: ce6bd21901ab
|
||||
Create Date: 2026-04-07 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e5f6a7b8c9d0"
|
||||
down_revision = "ce6bd21901ab"
|
||||
|
||||
import sqlalchemy as sa # noqa: E402
|
||||
from alembic import op # noqa: E402
|
||||
from sqlalchemy_utils import UUIDType # noqa: E402
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"extension_storage",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
|
||||
sa.Column("uuid", UUIDType(binary=True), nullable=False),
|
||||
sa.Column("extension_id", sa.String(255), nullable=False),
|
||||
sa.Column("user_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("resource_type", sa.String(64), nullable=True),
|
||||
sa.Column("resource_uuid", sa.String(36), nullable=True),
|
||||
sa.Column("key", sa.String(255), nullable=False),
|
||||
sa.Column("category", sa.String(64), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("value", sa.LargeBinary(2**24 - 1), nullable=False),
|
||||
sa.Column(
|
||||
"value_type",
|
||||
sa.String(255),
|
||||
nullable=False,
|
||||
server_default="application/json",
|
||||
),
|
||||
sa.Column(
|
||||
"is_encrypted",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("created_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("changed_on", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_by_fk", sa.Integer(), nullable=True),
|
||||
sa.Column("changed_by_fk", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_fk"],
|
||||
["ab_user.id"],
|
||||
name="fk_extension_storage_user_fk_ab_user",
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["created_by_fk"],
|
||||
["ab_user.id"],
|
||||
name="fk_extension_storage_created_by_fk_ab_user",
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["changed_by_fk"],
|
||||
["ab_user.id"],
|
||||
name="fk_extension_storage_changed_by_fk_ab_user",
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("uuid"),
|
||||
sa.UniqueConstraint(
|
||||
"extension_id",
|
||||
"user_fk",
|
||||
"resource_type",
|
||||
"resource_uuid",
|
||||
"key",
|
||||
name="uq_extension_storage_scoped_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ext_storage_extension_id",
|
||||
"extension_storage",
|
||||
["extension_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_ext_storage_lookup",
|
||||
"extension_storage",
|
||||
["extension_id", "user_fk", "resource_type", "resource_uuid", "key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ext_storage_lookup", "extension_storage")
|
||||
op.drop_index("ix_ext_storage_extension_id", "extension_storage")
|
||||
op.drop_table("extension_storage")
|
||||
@@ -193,15 +193,25 @@ class CacheManager:
|
||||
self._thumbnail_cache = SupersetCache()
|
||||
self._filter_state_cache = SupersetCache()
|
||||
self._explore_form_data_cache = ExploreFormDataCache()
|
||||
self._extension_ephemeral_state_cache = SupersetCache()
|
||||
self._distributed_coordination: (
|
||||
RedisCacheBackend | RedisSentinelCacheBackend | None
|
||||
) = None
|
||||
|
||||
@staticmethod
|
||||
def _init_cache(
|
||||
app: Flask, cache: Cache, cache_config_key: str, required: bool = False
|
||||
app: Flask,
|
||||
cache: Cache,
|
||||
config: str | dict[str, Any],
|
||||
required: bool = False,
|
||||
) -> None:
|
||||
cache_config = app.config[cache_config_key]
|
||||
if isinstance(config, dict):
|
||||
cache_config = config
|
||||
config_name = cache_config.get("CACHE_KEY_PREFIX", "unknown")
|
||||
else:
|
||||
cache_config = app.config[config]
|
||||
config_name = config
|
||||
|
||||
cache_type = cache_config.get("CACHE_TYPE")
|
||||
if (required and cache_type is None) or cache_type == "SupersetMetastoreCache":
|
||||
if cache_type is None and not app.debug:
|
||||
@@ -210,10 +220,10 @@ class CacheManager:
|
||||
"metadata database, for the following cache: `%s`. "
|
||||
"It is recommended to use `RedisCache`, `MemcachedCache` or "
|
||||
"another dedicated caching backend for production deployments",
|
||||
cache_config_key,
|
||||
config_name,
|
||||
)
|
||||
cache_type = CACHE_IMPORT_PATH
|
||||
cache_key_prefix = cache_config.get("CACHE_KEY_PREFIX", cache_config_key)
|
||||
cache_key_prefix = cache_config.get("CACHE_KEY_PREFIX", config_name)
|
||||
cache_config.update(
|
||||
{"CACHE_TYPE": cache_type, "CACHE_KEY_PREFIX": cache_key_prefix}
|
||||
)
|
||||
@@ -237,6 +247,12 @@ class CacheManager:
|
||||
"EXPLORE_FORM_DATA_CACHE_CONFIG",
|
||||
required=True,
|
||||
)
|
||||
self._init_cache(
|
||||
app,
|
||||
self._extension_ephemeral_state_cache,
|
||||
app.config.get("EXTENSIONS_EPHEMERAL_STORAGE", {}),
|
||||
required=True,
|
||||
)
|
||||
self._init_distributed_coordination(app)
|
||||
|
||||
def _init_distributed_coordination(self, app: Flask) -> None:
|
||||
@@ -284,6 +300,10 @@ class CacheManager:
|
||||
def explore_form_data_cache(self) -> Cache:
|
||||
return self._explore_form_data_cache
|
||||
|
||||
@property
|
||||
def extension_ephemeral_state_cache(self) -> Cache:
|
||||
return self._extension_ephemeral_state_cache
|
||||
|
||||
@property
|
||||
def distributed_coordination(
|
||||
self,
|
||||
|
||||
16
tests/unit_tests/extensions/storage/__init__.py
Normal file
16
tests/unit_tests/extensions/storage/__init__.py
Normal file
@@ -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.
|
||||
138
tests/unit_tests/extensions/storage/test_context.py
Normal file
138
tests/unit_tests/extensions/storage/test_context.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for extension context management."""
|
||||
|
||||
import pytest
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
from superset.extensions.context import (
|
||||
ConcreteExtensionContext,
|
||||
extension_context,
|
||||
get_context,
|
||||
get_current_extension_context,
|
||||
use_context,
|
||||
)
|
||||
|
||||
|
||||
def _create_test_manifest(
|
||||
publisher: str = "test-org", name: str = "test-extension"
|
||||
) -> Manifest:
|
||||
"""Create a test manifest with minimal required fields."""
|
||||
return Manifest.model_validate(
|
||||
{
|
||||
"id": f"{publisher}.{name}",
|
||||
"publisher": publisher,
|
||||
"name": name,
|
||||
"displayName": f"Test {name}",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_get_context_raises_outside_context():
|
||||
"""get_context() raises RuntimeError when called outside extension context."""
|
||||
with pytest.raises(
|
||||
RuntimeError, match="must be called within an extension context"
|
||||
):
|
||||
get_context()
|
||||
|
||||
|
||||
def test_get_current_extension_context_returns_none_outside_context():
|
||||
"""get_current_extension_context() returns None when called outside context."""
|
||||
assert get_current_extension_context() is None
|
||||
|
||||
|
||||
def test_use_context_sets_and_restores_context():
|
||||
"""use_context() sets context during execution and restores None after."""
|
||||
manifest = _create_test_manifest()
|
||||
ctx = ConcreteExtensionContext(manifest)
|
||||
|
||||
assert get_current_extension_context() is None
|
||||
|
||||
with use_context(ctx):
|
||||
assert get_current_extension_context() is ctx
|
||||
assert get_context() is ctx
|
||||
|
||||
assert get_current_extension_context() is None
|
||||
|
||||
|
||||
def test_use_context_supports_nesting():
|
||||
"""use_context() properly handles nested context switches."""
|
||||
manifest1 = _create_test_manifest("org1", "ext1")
|
||||
manifest2 = _create_test_manifest("org2", "ext2")
|
||||
ctx1 = ConcreteExtensionContext(manifest1)
|
||||
ctx2 = ConcreteExtensionContext(manifest2)
|
||||
|
||||
with use_context(ctx1):
|
||||
assert get_context().manifest.id == "org1.ext1"
|
||||
|
||||
with use_context(ctx2):
|
||||
assert get_context().manifest.id == "org2.ext2"
|
||||
|
||||
# ctx1 is restored after inner context exits
|
||||
assert get_context().manifest.id == "org1.ext1"
|
||||
|
||||
assert get_current_extension_context() is None
|
||||
|
||||
|
||||
def test_extension_context_creates_and_sets_context():
|
||||
"""extension_context() creates context from manifest and sets it."""
|
||||
manifest = _create_test_manifest("my-org", "my-ext")
|
||||
|
||||
with extension_context(manifest) as ctx:
|
||||
assert ctx.manifest is manifest
|
||||
assert get_context() is ctx
|
||||
assert get_context().manifest.id == "my-org.my-ext"
|
||||
|
||||
assert get_current_extension_context() is None
|
||||
|
||||
|
||||
def test_context_provides_extension_property():
|
||||
"""Context provides extension property as alias for manifest."""
|
||||
manifest = _create_test_manifest()
|
||||
ctx = ConcreteExtensionContext(manifest)
|
||||
|
||||
with use_context(ctx):
|
||||
assert get_context().extension is manifest
|
||||
assert get_context().extension.id == manifest.id
|
||||
|
||||
|
||||
def test_context_provides_storage_property():
|
||||
"""Context provides storage property with ephemeral tier."""
|
||||
manifest = _create_test_manifest()
|
||||
ctx = ConcreteExtensionContext(manifest)
|
||||
|
||||
with use_context(ctx):
|
||||
storage = get_context().storage
|
||||
assert storage is not None
|
||||
assert hasattr(storage, "ephemeral")
|
||||
|
||||
|
||||
def test_context_exception_still_restores():
|
||||
"""Context is properly restored even when exception occurs."""
|
||||
manifest = _create_test_manifest()
|
||||
ctx = ConcreteExtensionContext(manifest)
|
||||
|
||||
def raise_in_context() -> None:
|
||||
with use_context(ctx):
|
||||
assert get_current_extension_context() is ctx
|
||||
raise ValueError("test error")
|
||||
|
||||
with pytest.raises(ValueError, match="test error"):
|
||||
raise_in_context()
|
||||
|
||||
assert get_current_extension_context() is None
|
||||
251
tests/unit_tests/extensions/storage/test_ephemeral_state.py
Normal file
251
tests/unit_tests/extensions/storage/test_ephemeral_state.py
Normal file
@@ -0,0 +1,251 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for ephemeral state storage implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask, g
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
from superset.extensions.context import ConcreteExtensionContext, use_context
|
||||
from superset.extensions.storage.ephemeral_state import (
|
||||
_build_cache_key,
|
||||
EphemeralStateImpl,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
"""Create a minimal Flask app for testing."""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cache() -> MagicMock:
|
||||
"""Create a mock cache manager."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
def _create_context(
|
||||
publisher: str = "test-org", name: str = "test-ext"
|
||||
) -> ConcreteExtensionContext:
|
||||
"""Create test context with given extension identifiers."""
|
||||
manifest = Manifest.model_validate(
|
||||
{
|
||||
"id": f"{publisher}.{name}",
|
||||
"publisher": publisher,
|
||||
"name": name,
|
||||
"displayName": f"Test {name}",
|
||||
}
|
||||
)
|
||||
return ConcreteExtensionContext(manifest)
|
||||
|
||||
|
||||
def _set_user(user_id: int) -> None:
|
||||
"""Set a mock user on Flask's g object."""
|
||||
g.user = MagicMock(id=user_id)
|
||||
|
||||
|
||||
def test_build_cache_key_joins_parts_with_separator():
|
||||
"""_build_cache_key joins all parts with colon separator."""
|
||||
assert _build_cache_key("a", "b", "c") == "a:b:c"
|
||||
assert _build_cache_key("prefix", 123, "key") == "prefix:123:key"
|
||||
|
||||
|
||||
def test_ephemeral_state_raises_without_context(app: Flask) -> None:
|
||||
"""EphemeralStateImpl operations raise RuntimeError without extension context."""
|
||||
with app.app_context():
|
||||
_set_user(1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="within an extension context"):
|
||||
EphemeralStateImpl.get("key")
|
||||
|
||||
with pytest.raises(RuntimeError, match="within an extension context"):
|
||||
EphemeralStateImpl.set("key", "value")
|
||||
|
||||
with pytest.raises(RuntimeError, match="within an extension context"):
|
||||
EphemeralStateImpl.remove("key")
|
||||
|
||||
|
||||
def test_ephemeral_state_raises_without_user(app: Flask) -> None:
|
||||
"""EphemeralStateImpl operations raise RuntimeError without authenticated user."""
|
||||
ctx = _create_context()
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
# No user set on g
|
||||
with pytest.raises(RuntimeError, match="requires an authenticated user"):
|
||||
EphemeralStateImpl.get("key")
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_ephemeral_state_get_builds_correct_key(mock_cm: MagicMock, app: Flask) -> None:
|
||||
"""EphemeralStateImpl.get() builds user-scoped key and calls cache.get()."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
mock_cache.get.return_value = {"data": "test"}
|
||||
|
||||
ctx = _create_context("my-org", "my-ext")
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(42)
|
||||
result = EphemeralStateImpl.get("my-key")
|
||||
|
||||
expected_key = "superset-ext:my-org.my-ext:user:42:my-key"
|
||||
mock_cache.get.assert_called_once_with(expected_key)
|
||||
assert result == {"data": "test"}
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_ephemeral_state_set_builds_correct_key_and_uses_ttl(
|
||||
mock_cm: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""EphemeralStateImpl.set() builds user-scoped key and passes TTL to cache."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
|
||||
ctx = _create_context("my-org", "my-ext")
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(42)
|
||||
EphemeralStateImpl.set("my-key", {"value": 123}, ttl=600)
|
||||
|
||||
expected_key = "superset-ext:my-org.my-ext:user:42:my-key"
|
||||
mock_cache.set.assert_called_once_with(expected_key, {"value": 123}, timeout=600)
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_ephemeral_state_set_uses_cache_default_timeout(
|
||||
mock_cm: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""EphemeralStateImpl.set() passes timeout=None when ttl not specified,
|
||||
deferring to CACHE_DEFAULT_TIMEOUT in config."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
|
||||
ctx = _create_context()
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(1)
|
||||
EphemeralStateImpl.set("key", "value")
|
||||
|
||||
mock_cache.set.assert_called_once()
|
||||
call_args = mock_cache.set.call_args
|
||||
assert call_args.kwargs["timeout"] is None
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_ephemeral_state_remove_deletes_key(mock_cm: MagicMock, app: Flask) -> None:
|
||||
"""EphemeralStateImpl.remove() calls cache.delete() with correct key."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
|
||||
ctx = _create_context("org", "ext")
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(99)
|
||||
EphemeralStateImpl.remove("to-delete")
|
||||
|
||||
expected_key = "superset-ext:org.ext:user:99:to-delete"
|
||||
mock_cache.delete.assert_called_once_with(expected_key)
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_shared_accessor_builds_shared_key(mock_cm: MagicMock, app: Flask) -> None:
|
||||
"""SharedEphemeralStateAccessor builds key without user scope."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
mock_cache.get.return_value = "shared-value"
|
||||
|
||||
ctx = _create_context("org", "ext")
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(1) # User is set but should not appear in shared key
|
||||
result = EphemeralStateImpl.shared.get("shared-key")
|
||||
|
||||
expected_key = "superset-ext:org.ext:shared:shared-key"
|
||||
mock_cache.get.assert_called_once_with(expected_key)
|
||||
assert result == "shared-value"
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_shared_accessor_set_and_remove(mock_cm: MagicMock, app: Flask) -> None:
|
||||
"""SharedEphemeralStateAccessor set() and remove() use shared key."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
|
||||
ctx = _create_context("org", "ext")
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(1)
|
||||
EphemeralStateImpl.shared.set("key", {"shared": True}, ttl=300)
|
||||
EphemeralStateImpl.shared.remove("key")
|
||||
|
||||
expected_key = "superset-ext:org.ext:shared:key"
|
||||
mock_cache.set.assert_called_once_with(expected_key, {"shared": True}, timeout=300)
|
||||
mock_cache.delete.assert_called_once_with(expected_key)
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_different_extensions_have_isolated_keys(
|
||||
mock_cm: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""Different extensions use different key prefixes for isolation."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
mock_cache.get.side_effect = lambda k: f"value-for-{k}"
|
||||
|
||||
ctx1 = _create_context("org1", "ext1")
|
||||
ctx2 = _create_context("org2", "ext2")
|
||||
|
||||
with app.app_context():
|
||||
_set_user(1)
|
||||
|
||||
with use_context(ctx1):
|
||||
EphemeralStateImpl.get("same-key")
|
||||
|
||||
with use_context(ctx2):
|
||||
EphemeralStateImpl.get("same-key")
|
||||
|
||||
calls = [call.args[0] for call in mock_cache.get.call_args_list]
|
||||
assert "superset-ext:org1.ext1:user:1:same-key" in calls
|
||||
assert "superset-ext:org2.ext2:user:1:same-key" in calls
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.ephemeral_state.cache_manager")
|
||||
def test_different_users_have_isolated_keys(mock_cm: MagicMock, app: Flask) -> None:
|
||||
"""Different users use different key prefixes for isolation."""
|
||||
mock_cache = MagicMock()
|
||||
mock_cm.extension_ephemeral_state_cache = mock_cache
|
||||
|
||||
ctx = _create_context()
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(1)
|
||||
EphemeralStateImpl.get("key")
|
||||
|
||||
_set_user(2)
|
||||
EphemeralStateImpl.get("key")
|
||||
|
||||
calls = [call.args[0] for call in mock_cache.get.call_args_list]
|
||||
assert any(":user:1:" in k for k in calls)
|
||||
assert any(":user:2:" in k for k in calls)
|
||||
169
tests/unit_tests/extensions/storage/test_persistent_state.py
Normal file
169
tests/unit_tests/extensions/storage/test_persistent_state.py
Normal file
@@ -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.
|
||||
|
||||
"""Tests for persistent state storage implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask, g
|
||||
from superset_core.extensions.types import Manifest
|
||||
|
||||
from superset.extensions.context import ConcreteExtensionContext, use_context
|
||||
from superset.extensions.storage.persistent_state_impl import (
|
||||
PersistentStateImpl,
|
||||
SharedPersistentStateAccessor,
|
||||
)
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
"""Create a minimal Flask app for testing."""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
def _create_context(
|
||||
publisher: str = "test-org", name: str = "test-ext"
|
||||
) -> ConcreteExtensionContext:
|
||||
"""Create test context with given extension identifiers."""
|
||||
manifest = Manifest.model_validate(
|
||||
{
|
||||
"id": f"{publisher}.{name}",
|
||||
"publisher": publisher,
|
||||
"name": name,
|
||||
"displayName": f"Test {name}",
|
||||
}
|
||||
)
|
||||
return ConcreteExtensionContext(manifest)
|
||||
|
||||
|
||||
def _set_user(user_id: int) -> None:
|
||||
"""Set a mock user on Flask's g object."""
|
||||
g.user = MagicMock(id=user_id)
|
||||
|
||||
|
||||
@patch("superset.db")
|
||||
def test_persistent_state_raises_without_context(
|
||||
mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""PersistentStateImpl operations raise RuntimeError without extension context."""
|
||||
with app.app_context():
|
||||
_set_user(1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="within an extension context"):
|
||||
PersistentStateImpl.get("key")
|
||||
|
||||
with pytest.raises(RuntimeError, match="within an extension context"):
|
||||
PersistentStateImpl.set("key", "value")
|
||||
|
||||
with pytest.raises(RuntimeError, match="within an extension context"):
|
||||
PersistentStateImpl.remove("key")
|
||||
|
||||
|
||||
def test_persistent_state_raises_without_user(app: Flask) -> None:
|
||||
"""PersistentStateImpl operations raise RuntimeError without authenticated user."""
|
||||
ctx = _create_context()
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
with pytest.raises(RuntimeError, match="requires an authenticated user"):
|
||||
PersistentStateImpl.get("key")
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
|
||||
def test_persistent_state_get_returns_value(mock_dao: MagicMock, app: Flask) -> None:
|
||||
"""PersistentStateImpl.get returns decoded value from DAO."""
|
||||
ctx = _create_context()
|
||||
stored = json.dumps({"theme": "dark"}).encode()
|
||||
mock_dao.get_value.return_value = stored
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(42)
|
||||
result = PersistentStateImpl.get("prefs")
|
||||
|
||||
mock_dao.get_value.assert_called_once_with("test-org.test-ext", "prefs", user_fk=42)
|
||||
assert result == {"theme": "dark"}
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
|
||||
def test_persistent_state_get_returns_none_when_missing(
|
||||
mock_dao: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""PersistentStateImpl.get returns None when key does not exist."""
|
||||
ctx = _create_context()
|
||||
mock_dao.get_value.return_value = None
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(42)
|
||||
result = PersistentStateImpl.get("missing")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch("superset.db")
|
||||
@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
|
||||
def test_persistent_state_set_encodes_value(
|
||||
mock_dao: MagicMock, mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""PersistentStateImpl.set encodes value as JSON bytes."""
|
||||
ctx = _create_context()
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(42)
|
||||
PersistentStateImpl.set("prefs", {"theme": "dark"})
|
||||
|
||||
expected_bytes = json.dumps({"theme": "dark"}).encode()
|
||||
mock_dao.set.assert_called_once_with(
|
||||
"test-org.test-ext", "prefs", expected_bytes, user_fk=42
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.db")
|
||||
@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
|
||||
def test_persistent_state_remove_deletes_entry(
|
||||
mock_dao: MagicMock, mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""PersistentStateImpl.remove calls DAO delete."""
|
||||
ctx = _create_context()
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(42)
|
||||
PersistentStateImpl.remove("prefs")
|
||||
|
||||
mock_dao.delete.assert_called_once_with("test-org.test-ext", "prefs", user_fk=42)
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_impl.ExtensionStorageDAO")
|
||||
def test_shared_accessor_uses_null_user_fk(mock_dao: MagicMock, app: Flask) -> None:
|
||||
"""SharedPersistentStateAccessor uses user_fk=None for global scope."""
|
||||
ctx = _create_context()
|
||||
mock_dao.get_value.return_value = json.dumps("shared_value").encode()
|
||||
|
||||
accessor = SharedPersistentStateAccessor()
|
||||
|
||||
with app.app_context(), use_context(ctx):
|
||||
_set_user(42)
|
||||
result = accessor.get("config")
|
||||
|
||||
mock_dao.get_value.assert_called_once_with(
|
||||
"test-org.test-ext", "config", user_fk=None
|
||||
)
|
||||
assert result == "shared_value"
|
||||
217
tests/unit_tests/extensions/storage/test_persistent_state_dao.py
Normal file
217
tests/unit_tests/extensions/storage/test_persistent_state_dao.py
Normal file
@@ -0,0 +1,217 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for ExtensionStorageDAO — encryption, scoping, and CRUD behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import InvalidToken
|
||||
from flask import Flask
|
||||
|
||||
from superset.extensions.storage.persistent_state_dao import ExtensionStorageDAO
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
"""Create a minimal Flask app for testing."""
|
||||
flask_app = Flask(__name__)
|
||||
flask_app.config["TESTING"] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
# ── get / get_value ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_get_returns_none_when_not_found(mock_db: MagicMock, app: Flask) -> None:
|
||||
"""get() returns None when no entry exists for the given scope."""
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with app.app_context():
|
||||
result = ExtensionStorageDAO.get("my-ext", "key", user_fk=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_get_value_returns_raw_bytes_for_unencrypted(
|
||||
mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""get_value() returns raw bytes unchanged when the entry is not encrypted."""
|
||||
entry = MagicMock()
|
||||
entry.is_encrypted = False
|
||||
entry.value = b'{"foo": "bar"}'
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
|
||||
|
||||
with app.app_context():
|
||||
result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
|
||||
|
||||
assert result == b'{"foo": "bar"}'
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_get_value_returns_none_when_not_found(
|
||||
mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""get_value() returns None when the key does not exist."""
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with app.app_context():
|
||||
result = ExtensionStorageDAO.get_value("my-ext", "missing", user_fk=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao._fernet")
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_get_value_decrypts_encrypted_entry(
|
||||
mock_db: MagicMock, mock_fernet_fn: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""get_value() decrypts the stored value for encrypted entries."""
|
||||
entry = MagicMock()
|
||||
entry.is_encrypted = True
|
||||
entry.value = b"encrypted-bytes"
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
|
||||
mock_fernet_fn.return_value.decrypt.return_value = b'{"decrypted": true}'
|
||||
|
||||
with app.app_context():
|
||||
result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
|
||||
|
||||
assert result == b'{"decrypted": true}'
|
||||
mock_fernet_fn.return_value.decrypt.assert_called_once_with(b"encrypted-bytes")
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao._fernet")
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_get_value_returns_none_on_invalid_token(
|
||||
mock_db: MagicMock, mock_fernet_fn: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""get_value() returns None and logs an error when decryption fails."""
|
||||
entry = MagicMock()
|
||||
entry.is_encrypted = True
|
||||
entry.value = b"corrupted"
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
|
||||
mock_fernet_fn.return_value.decrypt.side_effect = InvalidToken()
|
||||
|
||||
with app.app_context():
|
||||
result = ExtensionStorageDAO.get_value("my-ext", "key", user_fk=1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── set (upsert) ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_set_creates_new_entry_when_absent(mock_db: MagicMock, app: Flask) -> None:
|
||||
"""set() adds a new entry when no existing entry is found."""
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with app.app_context():
|
||||
ExtensionStorageDAO.set("my-ext", "key", b'{"value": 1}', user_fk=1)
|
||||
|
||||
mock_db.session.add.assert_called_once()
|
||||
mock_db.session.flush.assert_called_once()
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_set_updates_existing_entry(mock_db: MagicMock, app: Flask) -> None:
|
||||
"""set() updates in-place when an entry already exists (no duplicate row)."""
|
||||
existing = MagicMock()
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = existing
|
||||
|
||||
with app.app_context():
|
||||
ExtensionStorageDAO.set("my-ext", "key", b'{"new": true}', user_fk=1)
|
||||
|
||||
assert existing.value == b'{"new": true}'
|
||||
mock_db.session.add.assert_not_called()
|
||||
mock_db.session.flush.assert_called_once()
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao._fernet")
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_set_encrypts_value_when_requested(
|
||||
mock_db: MagicMock, mock_fernet_fn: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""set() encrypts value bytes and sets is_encrypted=True when requested."""
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = None
|
||||
mock_fernet_fn.return_value.encrypt.return_value = b"ciphertext"
|
||||
|
||||
with app.app_context():
|
||||
ExtensionStorageDAO.set(
|
||||
"my-ext", "key", b"plaintext", user_fk=1, is_encrypted=True
|
||||
)
|
||||
|
||||
mock_fernet_fn.return_value.encrypt.assert_called_once_with(b"plaintext")
|
||||
added_entry = mock_db.session.add.call_args[0][0]
|
||||
assert added_entry.value == b"ciphertext"
|
||||
assert added_entry.is_encrypted is True
|
||||
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_delete_returns_true_when_entry_exists(
|
||||
mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""delete() returns True and removes the row when the entry is found."""
|
||||
entry = MagicMock()
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = entry
|
||||
|
||||
with app.app_context():
|
||||
result = ExtensionStorageDAO.delete("my-ext", "key", user_fk=1)
|
||||
|
||||
assert result is True
|
||||
mock_db.session.delete.assert_called_once_with(entry)
|
||||
mock_db.session.flush.assert_called_once()
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_delete_returns_false_when_not_found(
|
||||
mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""delete() returns False without touching the session when entry is absent."""
|
||||
mock_db.session.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
with app.app_context():
|
||||
result = ExtensionStorageDAO.delete("my-ext", "key", user_fk=1)
|
||||
|
||||
assert result is False
|
||||
mock_db.session.delete.assert_not_called()
|
||||
|
||||
|
||||
# ── scoping ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@patch("superset.extensions.storage.persistent_state_dao.db")
|
||||
def test_dao_user_and_shared_scopes_issue_independent_queries(
|
||||
mock_db: MagicMock, app: Flask
|
||||
) -> None:
|
||||
"""User-scoped (user_fk=N) and shared-scoped (user_fk=None) lookups are separate."""
|
||||
first_call = MagicMock(return_value=None)
|
||||
mock_db.session.query.return_value.filter.return_value.first = first_call
|
||||
|
||||
with app.app_context():
|
||||
ExtensionStorageDAO.get("my-ext", "key", user_fk=42)
|
||||
ExtensionStorageDAO.get("my-ext", "key", user_fk=None)
|
||||
|
||||
# Each scope issues its own independent DB query
|
||||
assert first_call.call_count == 2
|
||||
Reference in New Issue
Block a user