Files
superset2/superset/extensions/storage/persistent_model.py
Michael S. Molina 34ebe3d22a feat(extensions): add a Storage API for extensions (#39171)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Benyovszky Máté <benyovszky.mate.work@outlook.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 16:23:56 -03:00

140 lines
5.4 KiB
Python

# 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,
UniqueConstraint,
)
from sqlalchemy_utils import UUIDType
from superset_core.extensions.storage.models import (
ExtensionStorageEntry as CoreExtensionStorageEntry,
)
from superset.models.helpers import AuditMixinNullable
class ExtensionStorage(CoreExtensionStorageEntry, 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) alongside the identifier of
the codec used to encode it (codec), so the same bytes can be decoded
back into a value on read. When is_encrypted is True the value has been
encrypted at the DAO layer using Fernet and must be decrypted before
decoding.
"""
__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).
# No relationship() is declared for user_fk: extension storage rows are
# deleted at the database level when their owning user is (ondelete=
# "CASCADE"), and nothing reads through such a relationship, so there's
# no ORM object here for a future change to attach a cascade to.
# CASCADE (rather than SET NULL) is required here: setting user_fk to
# NULL would silently promote a deleted user's private rows into
# global/shared scope, exposing them to every user of the extension.
user_fk = Column(
Integer,
ForeignKey(
"ab_user.id",
ondelete="CASCADE",
name="fk_extension_storage_user_fk_ab_user",
),
nullable=True,
)
# No FK here: resource_type is a free-form string naming the referenced
# table (a built-in Superset model or a resource type defined by another
# extension entirely), so the table a given row's resource_uuid points
# into varies per row and can't be fixed at the schema level.
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)
# Payload. Size is bounded at the application level by
# EXTENSIONS_PERSISTENT_STORAGE["MAX_VALUE_SIZE"], enforced in
# ExtensionStorageDAO.set(), rather than by a fixed column length here.
value = Column(LargeBinary, nullable=False)
# Byte length of `value`, kept in sync at write time so the quota SUM in
# _check_quota() can be computed from this fixed-width column instead of
# reading (and, once TOASTed, detoasting) every extension's stored blobs.
value_size = Column(Integer, nullable=False)
codec = Column(String(255), nullable=False, default="json")
is_encrypted = Column(Boolean, nullable=False, default=False)
__table_args__ = (
# user_fk, resource_type, and resource_uuid are nullable, and
# standard SQL never treats NULL = NULL, so this constraint applies
# only when all three are non-NULL — a global/resource-scoped key
# (all three NULL) falls outside it.
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",
),
# Covers both extension_id-only lookups (leftmost prefix) and the
# quota SUM(value_size) query, letting the latter run as an
# index-only scan instead of touching the LargeBinary value column.
Index("ix_ext_storage_extension_id", "extension_id", "value_size"),
)
def __repr__(self) -> str:
return (
f"<ExtensionStorage {self.extension_id}/"
f"user={self.user_fk}/res={self.resource_type}/{self.key}>"
)