Source code for sqlspec.extensions.events._store

"""Base classes for adapter-specific event queue stores."""

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast

from sqlspec.exceptions import ImproperConfigurationError
from sqlspec.extensions.events._names import normalize_event_channel_name, normalize_queue_table_name
from sqlspec.migrations.schema import SchemaEnsureResult, SchemaTarget, ensure_schema_async, ensure_schema_sync

if TYPE_CHECKING:
    from sqlspec.config import DatabaseConfigProtocol

__all__ = ("BaseEventQueueStore", "normalize_event_channel_name", "normalize_queue_table_name")

ConfigT = TypeVar("ConfigT", bound="DatabaseConfigProtocol[Any, Any, Any]")


[docs] class BaseEventQueueStore(ABC, Generic[ConfigT]): """Base class for adapter-specific event queue DDL generators. This class provides a hook-based pattern for DDL generation. Adapters only need to override `_column_types()` and optionally any hook methods for dialect-specific variations: - `_string_type(length)`: String type syntax (default: VARCHAR(N)) - `_integer_type()`: Integer type syntax (default: INTEGER) - `_timestamp_default()`: Timestamp default expression (default: CURRENT_TIMESTAMP) - `_primary_key_syntax()`: Inline PRIMARY KEY clause (default: empty, PK on column) - `_table_clause()`: Additional table options (default: empty) For complex dialects (Oracle PL/SQL, BigQuery CLUSTER BY), adapters may override `_table_ddl()` directly. """ __slots__ = ("_config", "_extension_settings", "_table_name") extension_config_options: ClassVar[frozenset[str]] = frozenset({ "backend", "create_schema", "event_poll_interval", "lease_seconds", "manage_schema", "migrations_path", "poll_interval", "queue_table", "retention_seconds", "run_migrations", "select_for_update", "skip_locked", })
[docs] def __init__(self, config: ConfigT) -> None: self._config = config extension_config = cast("dict[str, Any]", config.extension_config) self._extension_settings = cast("dict[str, Any]", extension_config.get("events", {})) self._validate_extension_config() table_name = self._extension_settings.get("queue_table", "sqlspec_event_queue") self._table_name = normalize_queue_table_name(str(table_name))
@property def table_name(self) -> str: """Return the configured queue table name.""" return self._table_name @property def settings(self) -> "dict[str, Any]": """Return extension settings for adapters to inspect.""" return self._extension_settings
[docs] def create_statements(self) -> "list[str]": """Return statements required to create the queue table and indexes.""" statements = [self._wrap_create_statement(self._table_ddl(), "table")] index_statement = self._index_ddl() if index_statement: statements.append(self._wrap_create_statement(index_statement, "index")) return statements
[docs] def drop_statements(self) -> "list[str]": """Return statements required to drop queue artifacts.""" return [self._wrap_drop_statement(f"DROP TABLE {self.table_name}")]
[docs] def prepare_schema_sync(self, driver: Any) -> None: """Prepare adapter-specific schema decisions with a synchronous driver."""
[docs] async def prepare_schema_async(self, driver: Any) -> None: """Prepare adapter-specific schema decisions with an asynchronous driver."""
[docs] def reconcile_schema_sync(self, driver: Any) -> SchemaEnsureResult: """Apply additive queue-table changes with a synchronous driver.""" manage_schema, create_schema = self._schema_management_flags() if not manage_schema: return ensure_schema_sync(driver, [], manage_schema=False) return ensure_schema_sync(driver, [self._schema_target()], manage_schema=True, create_schema=create_schema)
[docs] async def reconcile_schema_async(self, driver: Any) -> SchemaEnsureResult: """Apply additive queue-table changes with an asynchronous driver.""" manage_schema, create_schema = self._schema_management_flags() if not manage_schema: return await ensure_schema_async(driver, [], manage_schema=False) return await ensure_schema_async( driver, [self._schema_target()], manage_schema=True, create_schema=create_schema )
def _schema_target(self) -> SchemaTarget: """Build a schema target from the canonical queue table DDL.""" statement_config = getattr(self._config, "statement_config", None) dialect = getattr(statement_config, "dialect", None) return SchemaTarget.from_ddl(self.table_name, self.create_statements()[0], dialect=dialect) def _validate_extension_config(self) -> None: """Reject events options that this adapter store cannot honor.""" unsupported = sorted(set(self._extension_settings).difference(type(self).extension_config_options)) if unsupported: adapter = type(self).__module__.split(".")[2] keys = ", ".join(repr(key) for key in unsupported) msg = f"Unsupported events configuration key(s) for {adapter}: {keys}" raise ImproperConfigurationError(msg) def _schema_management_flags(self) -> "tuple[bool, bool]": """Return automatic-management and missing-table creation flags.""" return bool(self.settings.get("manage_schema", True)), bool(self.settings.get("create_schema", True)) def _string_type(self, length: int) -> str: """Return string type syntax for the given length. Override for dialects with different string type syntax. Args: length: Maximum string length. Returns: String type declaration. """ return f"VARCHAR({length})" def _integer_type(self) -> str: """Return integer type syntax. Override for dialects with different integer type syntax. Returns: Integer type declaration. """ return "INTEGER" def _timestamp_default(self) -> str: """Return timestamp default expression. Override for dialects requiring different default syntax. Returns: Default timestamp expression. """ return "CURRENT_TIMESTAMP" def _primary_key_syntax(self) -> str: """Return inline PRIMARY KEY clause for table definition. Override for dialects that require PRIMARY KEY at the end of CREATE TABLE instead of on the column definition. Returns: Empty string for column-level PK, or " PRIMARY KEY (event_id)" for table-level. """ return "" def _table_ddl(self) -> str: """Build CREATE TABLE SQL using hook methods. Most adapters should NOT override this method. Instead, override the hook methods (_string_type, _integer_type, _timestamp_default, etc.) for dialect-specific variations. Only override this method for complex dialects that require entirely different DDL structure. """ payload_type, metadata_type, timestamp_type = self._column_types() string_64 = self._string_type(64) string_128 = self._string_type(128) string_32 = self._string_type(32) integer_type = self._integer_type() ts_default = self._timestamp_default() pk_inline = self._primary_key_syntax() table_clause = self._table_clause() pk_column = " PRIMARY KEY" if not pk_inline else "" return f"CREATE TABLE {self.table_name} (event_id {string_64}{pk_column}, channel {string_128} NOT NULL, payload_json {payload_type} NOT NULL, metadata_json {metadata_type}, status {string_32} NOT NULL DEFAULT 'pending', available_at {timestamp_type} NOT NULL DEFAULT {ts_default}, lease_expires_at {timestamp_type}, attempts {integer_type} NOT NULL DEFAULT 0, created_at {timestamp_type} NOT NULL DEFAULT {ts_default}, acknowledged_at {timestamp_type}){pk_inline}{table_clause}" def _index_ddl(self) -> str | None: """Build CREATE INDEX SQL for queue operations.""" index_name = self._index_name() return f"CREATE INDEX {index_name} ON {self.table_name}(channel, status, available_at)" def _table_clause(self) -> str: """Return additional table options clause. Override for dialects that need options after the column definitions. """ return "" def _index_name(self) -> str: """Return the index name for the queue table.""" return f"idx_{self.table_name.replace('.', '_')}_channel_status" def _index_existence_target(self) -> "tuple[str | None, str] | None": """Return the ``(schema, table)`` target for a data-dictionary index check. Adapters whose index DDL is self-idempotent (``CREATE INDEX IF NOT EXISTS``) return ``None``: no external existence check is needed. Adapters whose dialect lacks an idempotent index DDL (MySQL) return the schema and table so the migration can consult ``driver.data_dictionary.get_indexes`` and skip the ``ADD INDEX`` statement when the index already exists. """ return None def _wrap_create_statement(self, statement: str, object_type: str) -> str: """Wrap CREATE statement with IF NOT EXISTS. Override for dialects that don't support IF NOT EXISTS. """ if object_type == "table": return statement.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1) if object_type == "index": return statement.replace("CREATE INDEX", "CREATE INDEX IF NOT EXISTS", 1) return statement def _wrap_drop_statement(self, statement: str) -> str: """Wrap DROP statement with IF EXISTS. Override for dialects that don't support IF EXISTS. """ return statement.replace("DROP TABLE", "DROP TABLE IF EXISTS", 1) @abstractmethod def _column_types(self) -> "tuple[str, str, str]": """Return payload, metadata, and timestamp column types for the adapter. Args: None Returns: Tuple of (payload_type, metadata_type, timestamp_type). """