diff --git a/AGENTS.md b/AGENTS.md index a1ab765bf..89438538d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Quick Reference + +This file contains **MANDATORY rules** and **unique patterns**. For detailed implementations, see: + +| Category | Location | +|----------|----------| +| Implementation Patterns | `docs/guides/development/implementation-patterns.md` | +| Code Standards | `docs/guides/development/code-standards.md` | +| SQLGlot Dialects | `docs/guides/architecture/custom-sqlglot-dialects.md` | +| Events Extension | `docs/guides/events/database-event-channels.md` | + ## Project Overview SQLSpec is a type-safe SQL query mapper for Python - NOT an ORM. It provides flexible connectivity with consistent interfaces across multiple database systems. Write raw SQL, use the builder API, or load SQL from files. All statements pass through a sqlglot-powered AST pipeline for validation and dialect conversion. @@ -159,90 +170,7 @@ class MyAdapterDriver(SyncDriverBase): ### Protocol Abstract Methods Pattern -When adding methods that need to support both sync and async configurations, use this pattern: - -**Step 1: Define abstract method in protocol** - -```python -from abc import abstractmethod -from typing import Awaitable - -class DatabaseConfigProtocol(Protocol): - is_async: ClassVar[bool] # Set by base classes - - @abstractmethod - def migrate_up( - self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False - ) -> "Awaitable[None] | None": - """Apply database migrations up to specified revision. - - Args: - revision: Target revision or "head" for latest. - allow_missing: Allow out-of-order migrations. - auto_sync: Auto-reconcile renamed migrations. - dry_run: Show what would be done without applying. - """ - raise NotImplementedError -``` - -**Step 2: Implement in sync base class (no async/await)** - -```python -class NoPoolSyncConfig(DatabaseConfigProtocol): - is_async: ClassVar[bool] = False - - def migrate_up( - self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False - ) -> None: - """Apply database migrations up to specified revision.""" - commands = self._ensure_migration_commands() - commands.upgrade(revision, allow_missing, auto_sync, dry_run) -``` - -**Step 3: Implement in async base class (with async/await)** - -```python -class NoPoolAsyncConfig(DatabaseConfigProtocol): - is_async: ClassVar[bool] = True - - async def migrate_up( - self, revision: str = "head", allow_missing: bool = False, auto_sync: bool = True, dry_run: bool = False - ) -> None: - """Apply database migrations up to specified revision.""" - commands = cast("AsyncMigrationCommands", self._ensure_migration_commands()) - await commands.upgrade(revision, allow_missing, auto_sync, dry_run) -``` - -**Key principles:** - -- Protocol defines the interface with union return type (`Awaitable[T] | T`) -- Sync base classes implement without `async def` or `await` -- Async base classes implement with `async def` and `await` -- Each base class has concrete implementation - no need for child classes to override -- Use `cast()` to narrow types when delegating to command objects -- All 4 base classes (NoPoolSyncConfig, NoPoolAsyncConfig, SyncDatabaseConfig, AsyncDatabaseConfig) implement the same way - -**Benefits:** - -- Single source of truth (protocol) for API contract -- Each base class provides complete implementation -- Child adapter classes (AsyncpgConfig, SqliteConfig, etc.) inherit working methods automatically -- Type checkers understand sync vs async based on `is_async` class variable -- No code duplication across adapters - -**When to use:** - -- Adding convenience methods that delegate to external command objects -- Methods that need identical behavior across all adapters -- Operations that differ only in sync vs async execution -- Any protocol method where behavior is determined by sync/async mode - -**Anti-patterns to avoid:** - -- Don't use runtime `if self.is_async:` checks in a single implementation -- Don't make protocol methods concrete (always use `@abstractmethod`) -- Don't duplicate logic across the 4 base classes -- Don't forget to update all 4 base classes when adding new methods +→ See `docs/guides/development/implementation-patterns.md#protocol-abstract-methods-pattern` ### Database Connection Flow @@ -321,57 +249,10 @@ def test_starlette_autocommit_mode() -> None: ### Type Guards Pattern -When checking object capabilities, ALWAYS use type guards from `sqlspec.utils.type_guards` instead of `hasattr()`: +Use guards from `sqlspec.utils.type_guards` instead of `hasattr()`: +`is_readable`, `has_array_interface`, `has_cursor_metadata`, `has_expression_and_sql`, `has_expression_and_parameters`, `is_statement_filter` -```python -# NEVER do this - breaks mypyc and is slow -if hasattr(obj, "expression") and hasattr(obj, "sql"): - sql = obj.sql - expr = obj.expression - -# ALWAYS do this - type-safe and fast -from sqlspec.utils.type_guards import has_expression_and_sql -if has_expression_and_sql(obj): - sql = obj.sql # Type checker knows these exist - expr = obj.expression -``` - -**Available type guards:** - -| Guard | Checks For | Use When | -|-------|------------|----------| -| `is_readable(obj)` | `.read()` method | Checking LOB/stream objects | -| `has_array_interface(obj)` | `.dtype` and `.shape` | NumPy-like arrays | -| `has_cursor_metadata(obj)` | `.description` | Cursor metadata access | -| `has_expression_and_sql(obj)` | `.expression` and `.sql` | SQL statement objects | -| `has_expression_and_parameters(obj)` | `.expression` and `.parameters` | Parameterized statements | -| `is_statement_filter(obj)` | `.append_to_statement()` | Statement filter objects | - -**Creating new type guards:** - -1. Add protocol to `sqlspec/protocols.py`: - -```python -class MyProtocol(Protocol): - """Protocol for objects with specific capability.""" - my_attribute: str - def my_method(self) -> None: ... -``` - -2. Add type guard to `sqlspec/utils/type_guards.py`: - -```python -def has_my_capability(obj: Any) -> TypeGuard[MyProtocol]: - """Check if object has my_attribute and my_method.""" - return hasattr(obj, "my_attribute") and hasattr(obj, "my_method") -``` - -**Key principles:** - -- Type guards centralize `hasattr()` calls in ONE place -- Protocols provide type narrowing after the guard passes -- Type checkers understand the guard's implications -- Works with mypyc compilation +→ See `docs/guides/development/code-standards.md#type-guards` ### Testing @@ -382,11 +263,9 @@ def has_my_capability(obj: Any) -> TypeGuard[MyProtocol]: ### Mypyc Compatibility -For classes in `sqlspec/core/` and `sqlspec/driver/`: +Use `__slots__`, explicit `__init__/__repr__/__eq__/__hash__`, avoid dataclasses in `sqlspec/core/` and `sqlspec/driver/`. -- Use `__slots__` for data-holding classes -- Implement explicit `__init__`, `__repr__`, `__eq__`, `__hash__` -- Avoid dataclasses in performance-critical paths +→ See `docs/guides/development/code-standards.md#mypyc-compatible-class-pattern` ## Detailed Guides @@ -398,6 +277,8 @@ For detailed implementation patterns, consult these guides: | Data Flow | `docs/guides/architecture/data-flow.md` | | Arrow Integration | `docs/guides/architecture/arrow-integration.md` | | Query Stack Patterns | `docs/guides/architecture/patterns.md` | +| EXPLAIN Plans | `docs/guides/builder/explain.md` | +| MERGE Statements | `docs/guides/builder/merge.md` | | Testing | `docs/guides/testing/testing.md` | | Mypyc Optimization | `docs/guides/performance/mypyc.md` | | SQLglot Best Practices | `docs/guides/performance/sqlglot.md` | @@ -453,616 +334,25 @@ Prohibited: test coverage tables, file change lists, quality metrics, commit bre ## Key Patterns Quick Reference -### driver_features Pattern - -```python -class AdapterDriverFeatures(TypedDict): - """Feature flags with enable_ prefix for booleans.""" - enable_feature: NotRequired[bool] - json_serializer: NotRequired[Callable[[Any], str]] - -# Auto-detect in config __init__ -if "enable_feature" not in driver_features: - driver_features["enable_feature"] = OPTIONAL_PACKAGE_INSTALLED -``` - -### Type Handler Pattern - -```python -# In adapter's _feature_handlers.py -def register_handlers(connection: "Connection") -> None: - if not OPTIONAL_PACKAGE_INSTALLED: - logger.debug("Package not installed - skipping handlers") - return - connection.inputtypehandler = _input_type_handler - connection.outputtypehandler = _output_type_handler -``` - -### Binary Data Encoding Pattern (Spanner) - -For databases requiring specific binary data encoding (e.g., Spanner's base64 requirement): - -```python -# In adapter's _type_handlers.py -import base64 - -def bytes_to_database(value: bytes | None) -> bytes | None: - """Convert Python bytes to database-required format. - - Spanner Python client requires base64-encoded bytes when - param_types.BYTES is specified. - """ - if value is None: - return None - return base64.b64encode(value) - -def database_to_bytes(value: Any) -> bytes | None: - """Convert database BYTES result back to Python bytes. - - Handles both raw bytes and base64-encoded bytes. - """ - if value is None: - return None - if isinstance(value, bytes | str): - return base64.b64decode(value) - return None -``` - -**Use this pattern when**: - -- Database client library requires specific encoding for binary data -- Need transparent conversion between Python bytes and database format -- Want to centralize encoding/decoding logic for reuse - -**Key principles**: - -- Centralize conversion functions in `_type_handlers.py` -- Handle None/NULL values explicitly -- Support both raw and encoded formats on read (graceful handling) -- Use in `coerce_params_for_database()` and type converter - -**Example**: Spanner requires base64-encoded bytes for `param_types.BYTES` parameters, but you work with raw Python bytes in application code. - -### Instance-Based Config Registry Pattern - -SQLSpec uses config instances as handles for database connections. The registry keys by `id(config)` instead of `type(config)` to support multiple databases of the same adapter type. - -```python -from sqlspec import SQLSpec -from sqlspec.adapters.asyncpg import AsyncpgConfig - -manager = SQLSpec() - -# Config instance IS the handle - add_config returns same instance -main_db = manager.add_config(AsyncpgConfig(connection_config={"dsn": "postgresql://main/..."})) -analytics_db = manager.add_config(AsyncpgConfig(connection_config={"dsn": "postgresql://analytics/..."})) - -# Type checker knows: AsyncpgConfig → AsyncContextManager[AsyncpgDriver] -async with manager.provide_session(main_db) as driver: - await driver.execute("SELECT 1") - -# Different connection pool! Works correctly now. -async with manager.provide_session(analytics_db) as driver: - await driver.execute("SELECT 1") -``` - -**Use this pattern when**: - -- Managing multiple databases of the same adapter type (e.g., main + analytics PostgreSQL) -- Integrating with DI frameworks (config instance is the dependency) -- Need type-safe session handles without `# type: ignore` - -**Key principles**: - -- Registry uses `id(config)` as key (not `type(config)`) -- Multiple configs of same adapter type are stored separately -- `add_config` returns the same instance passed in -- All methods require registered config instances -- Unregistered configs raise `ValueError` - -**DI framework integration**: - -```python -# Just pass the config instance - it's already correctly typed -def get_main_db() -> AsyncpgConfig: - return main_db - -# DI provider knows this is async from the config type -async def provide_db_session(db: AsyncpgConfig, manager: SQLSpec): - async with manager.provide_session(db) as driver: - yield driver -``` - -**Reference implementation**: `sqlspec/base.py` (lines 58, 128-151, 435-581) - -### Framework Extension Pattern - -All extensions use `extension_config` in database config: - -```python -config = AsyncpgConfig( - connection_config={"dsn": "postgresql://..."}, - extension_config={ - "starlette": {"commit_mode": "autocommit", "session_key": "db"} - } -) -``` - -### Custom SQLGlot Expression Pattern - -For dialect-specific SQL generation (e.g., vector distance functions): - -```python -# In sqlspec/builder/_custom_expressions.py -from sqlglot import exp -from typing import Any - -class CustomExpression(exp.Expression): - """Custom expression with dialect-aware SQL generation.""" - arg_types = {"this": True, "expression": True, "metric": False} - - def sql(self, dialect: "Any | None" = None, **opts: Any) -> str: - """Override sql() method for dialect-specific generation.""" - dialect_name = str(dialect).lower() if dialect else "generic" - - left_sql = self.left.sql(dialect=dialect, **opts) - right_sql = self.right.sql(dialect=dialect, **opts) - - if dialect_name == "postgres": - return self._sql_postgres(left_sql, right_sql) - if dialect_name == "mysql": - return self._sql_mysql(left_sql, right_sql) - return self._sql_generic(left_sql, right_sql) - -# Register with SQLGlot generator system -def _register_with_sqlglot() -> None: - from sqlglot.dialects.postgres import Postgres - from sqlglot.generator import Generator - - def custom_sql_base(generator: "Generator", expression: "CustomExpression") -> str: - return expression._sql_generic(generator.sql(expression.left), generator.sql(expression.right)) - - Generator.TRANSFORMS[CustomExpression] = custom_sql_base - Postgres.Generator.TRANSFORMS[CustomExpression] = custom_sql_postgres - -_register_with_sqlglot() -``` - -**Use this pattern when**: - -- Database syntax varies significantly across dialects -- Standard SQLGlot expressions don't match any database's native syntax -- Need operator syntax (e.g., `<->`) vs function calls (e.g., `DISTANCE()`) - -**Key principles**: - -- Override `.sql()` method for dialect detection -- Register with SQLGlot's TRANSFORMS for nested expression support -- Store metadata (like metric) as `exp.Identifier` in `arg_types` for runtime access -- Provide generic fallback for unsupported dialects - -**Example**: `VectorDistance` in `sqlspec/builder/_vector_expressions.py` generates: - -- PostgreSQL: `embedding <-> '[0.1,0.2]'` (operator) -- MySQL: `DISTANCE(embedding, STRING_TO_VECTOR('[0.1,0.2]'), 'EUCLIDEAN')` (function) -- Oracle: `VECTOR_DISTANCE(embedding, TO_VECTOR('[0.1,0.2]'), EUCLIDEAN)` (function) - -### Custom SQLglot Dialect Pattern - -For databases with unique SQL syntax not supported by existing sqlglot dialects: - -```python -# In sqlspec/adapters/{adapter}/dialect/_dialect.py -from sqlglot import exp -from sqlglot.dialects.bigquery import BigQuery -from sqlglot.tokens import TokenType - -class CustomDialect(BigQuery): - """Inherit from closest matching dialect.""" - - class Tokenizer(BigQuery.Tokenizer): - """Add custom keywords.""" - KEYWORDS = { - **BigQuery.Tokenizer.KEYWORDS, - "INTERLEAVE": TokenType.INTERLEAVE, - } - - class Parser(BigQuery.Parser): - """Override parser for custom syntax.""" - def _parse_table_parts(self, schema=False, is_db_reference=False, wildcard=False): - table = super()._parse_table_parts(schema=schema, is_db_reference=is_db_reference, wildcard=wildcard) - - # Parse custom clause - if self._match_text_seq("INTERLEAVE", "IN", "PARENT"): - parent = self._parse_table(schema=True, is_db_reference=True) - table.set("interleave_parent", parent) - - return table - - class Generator(BigQuery.Generator): - """Override generator for custom SQL output.""" - def table_sql(self, expression, sep=" "): - sql = super().table_sql(expression, sep=sep) - - # Generate custom clause - parent = expression.args.get("interleave_parent") - if parent: - sql = f"{sql}\nINTERLEAVE IN PARENT {self.sql(parent)}" - - return sql - -# Register dialect in adapter __init__.py -from sqlglot.dialects.dialect import Dialect -Dialect.classes["custom"] = CustomDialect -``` - -**Create custom dialect when**: - -- Database has unique DDL/DML syntax not in existing dialects -- Need to parse and validate database-specific keywords -- Need to generate database-specific SQL from AST -- An existing dialect provides 80%+ compatibility to inherit from - -**Do NOT create custom dialect if**: - -- Only parameter style differences (use parameter profiles) -- Only type conversion differences (use type converters) -- Only connection management differences (use config/driver) - -**Key principles**: - -- **Inherit from closest dialect**: Spanner inherits BigQuery (both GoogleSQL) -- **Minimal overrides**: Only override methods that need customization -- **Store metadata in AST**: Use `expression.set(key, value)` for custom data -- **Handle missing tokens**: Check `getattr(TokenType, "KEYWORD", None)` before using -- **Test thoroughly**: Unit tests for parsing/generation, integration tests with real DB - -**Reference implementation**: `sqlspec/adapters/spanner/dialect/` (GoogleSQL and PostgreSQL modes) - -**Documentation**: See `/docs/guides/architecture/custom-sqlglot-dialects.md` for full guide - -### Configuration Parameter Standardization Pattern - -For API consistency across all adapters (pooled and non-pooled): - -```python -# ALL configs accept these parameters for consistency: -class AdapterConfig(AsyncDatabaseConfig): # or SyncDatabaseConfig - def __init__( - self, - *, - connection_config: dict[str, Any] | None = None, # Settings dict - connection_instance: PoolT | None = None, # Pre-created pool/connection - ... - ) -> None: - super().__init__( - connection_config=connection_config, - connection_instance=connection_instance, - ... - ) -``` - -**Key principles:** - -- `connection_config` holds ALL connection and pool configuration (unified dict) -- `connection_instance` accepts pre-created pools or connections (for dependency injection) -- Works semantically for both pooled (AsyncPG) and non-pooled adapters (BigQuery, ADBC) -- Non-pooled adapters accept `connection_instance` for API consistency (even if always None) -- NoPoolSyncConfig and NoPoolAsyncConfig accept `connection_instance: Any = None` for flexibility - -**Why this pattern:** - -- Consistent API eliminates cognitive load when switching adapters -- Clear separation: config dict vs pre-created instance -- Supports dependency injection scenarios -- Better than adapter-specific parameter names - -**Migration from old names:** - -- v0.33.0+: `pool_config` → `connection_config`, `pool_instance` → `connection_instance` - -### Parameter Deprecation Pattern - -For backwards-compatible parameter renames in configuration classes: - -```python -def __init__( - self, - *, - new_param: dict[str, Any] | None = None, - **kwargs: Any, # Capture old parameter names -) -> None: - from sqlspec.utils.deprecation import warn_deprecation - - if "old_param" in kwargs: - warn_deprecation( - version="0.33.0", - deprecated_name="old_param", - kind="parameter", - removal_in="0.34.0", - alternative="new_param", - info="Parameter renamed for consistency across pooled and non-pooled adapters", - ) - if new_param is None: - new_param = kwargs.pop("old_param") - else: - kwargs.pop("old_param") # Discard if new param provided - - # Continue with initialization using new_param -``` - -**Use this pattern when:** - -- Renaming configuration parameters for consistency -- Need backwards compatibility during migration period -- Want clear deprecation warnings for users - -**Key principles:** - -- Use `**kwargs` to capture old parameter names without changing signature -- Import `warn_deprecation` inside function to avoid circular imports -- New parameter takes precedence when both old and new provided -- Use `kwargs.pop()` to remove handled parameters and avoid `**kwargs` passing issues -- Provide clear migration path (version, alternative, removal timeline) -- Set removal timeline (typically next minor or major version) - -**Reference implementation:** `sqlspec/config.py` (lines 920-1517, all 4 base config classes) +| Pattern | Reference | +|---------|-----------| +| driver_features | `docs/guides/development/implementation-patterns.md#driver-features-pattern` | +| Type Handler | `docs/guides/development/implementation-patterns.md#type-handler-pattern` | +| Framework Extension | `docs/guides/development/implementation-patterns.md#framework-extension-pattern` | +| EXPLAIN Builder | `docs/guides/development/implementation-patterns.md#explain-builder-pattern` | +| Custom SQLGlot Dialect | `docs/guides/architecture/custom-sqlglot-dialects.md#custom-sqlglot-dialect` | +| Events Extension | `docs/guides/events/database-event-channels.md#events-architecture` | +| Binary Data Encoding | `sqlspec/adapters/spanner/_type_handlers.py` | +| Instance-Based Config | `sqlspec/base.py` | +| Config Param Standard | `sqlspec/config.py` base classes | +| Parameter Deprecation | `sqlspec/utils/deprecation.py` | +| CLI Patterns | `sqlspec/cli.py` | ### Error Handling -- Custom exceptions inherit from `SQLSpecError` in `sqlspec/exceptions.py` -- Use `wrap_exceptions` context manager in adapter layer -- Two-tier pattern: graceful skip (DEBUG) for expected conditions, hard errors for malformed input - -### Click Environment Variable Pattern - -When adding CLI options that should support environment variables: - -**Use Click's native `envvar` parameter instead of custom parsing:** - -```python -# Good - Click handles env var automatically -@click.option( - "--config", - help="Dotted path to SQLSpec config(s) (env: SQLSPEC_CONFIG)", - required=False, - type=str, - envvar="SQLSPEC_CONFIG", # Click handles precedence: CLI flag > env var -) -def command(config: str | None): - pass - -# Bad - Custom env var parsing -import os - -@click.option("--config", required=False, type=str) -def command(config: str | None): - if config is None: - config = os.getenv("SQLSPEC_CONFIG") # Don't do this! -``` - -**Benefits:** - -- Click automatically handles precedence (CLI flag always overrides env var) -- Help text automatically shows env var name -- Support for multiple fallback env vars via `envvar=["VAR1", "VAR2"]` -- Less code, fewer bugs - -**For project file discovery (pyproject.toml, etc.):** - -- Use custom logic as fallback after Click env var handling -- Walk filesystem from cwd to find config files -- Return `None` if not found to trigger helpful error message - -**Multi-config support:** - -- Split comma-separated values from CLI flag, env var, or pyproject.toml -- Resolve each config path independently -- Flatten results if callables return lists -- Deduplicate by `bind_key` (later configs override earlier ones with same key) - -**Reference implementation:** `sqlspec/cli.py` (lines 26-110), `sqlspec/utils/config_discovery.py` - -### CLI Sync/Async Dispatch Pattern - -When implementing CLI commands that support both sync and async database adapters: - -**Problem:** Sync adapters (SQLite, DuckDB, BigQuery, ADBC, Spanner sync, Psycopg sync, Oracle sync) fail with `await_ cannot be called from within an async task` if wrapped in an event loop. - -**Solution:** Partition configs and execute sync/async batches separately: - -```python -def _execute_for_config( - config: "AsyncDatabaseConfig[Any, Any, Any] | SyncDatabaseConfig[Any, Any, Any]", - sync_fn: "Callable[[], Any]", - async_fn: "Callable[[], Any]", -) -> Any: - """Execute with appropriate sync/async handling.""" - from sqlspec.utils.sync_tools import run_ - - if config.is_async: - return run_(async_fn)() - return sync_fn() - -def _partition_configs_by_async( - configs: "list[tuple[str, Any]]", -) -> "tuple[list[tuple[str, Any]], list[tuple[str, Any]]]": - """Partition configs into sync and async groups.""" - sync_configs = [(name, cfg) for name, cfg in configs if not cfg.is_async] - async_configs = [(name, cfg) for name, cfg in configs if cfg.is_async] - return sync_configs, async_configs -``` - -**Usage pattern for single-config operations:** - -```python -def _operation_for_config(config: Any) -> None: - migration_commands: SyncMigrationCommands[Any] | AsyncMigrationCommands[Any] = ( - create_migration_commands(config=config) - ) - - def sync_operation() -> None: - migration_commands.operation() - - async def async_operation() -> None: - await cast("AsyncMigrationCommands[Any]", migration_commands).operation() - - _execute_for_config(config, sync_operation, async_operation) -``` - -**Usage pattern for multi-config operations:** - -```python -# Partition first -sync_configs, async_configs = _partition_configs_by_async(configs_to_process) - -# Process sync configs directly (no event loop) -for config_name, config in sync_configs: - _operation_for_config(config) - -# Process async configs via single run_() call -if async_configs: - async def _run_async_configs() -> None: - for config_name, config in async_configs: - migration_commands: AsyncMigrationCommands[Any] = cast( - "AsyncMigrationCommands[Any]", create_migration_commands(config=config) - ) - await migration_commands.operation() - - run_(_run_async_configs)() -``` - -**Key principles:** - -- Sync configs must execute outside event loop (direct function calls) -- Async configs must execute inside event loop (via `run_()`) -- Multi-config operations should batch sync and async separately for efficiency -- Use `cast()` in async contexts for type safety with migration commands - -**Reference implementation:** `sqlspec/cli.py` (lines 218-255, 311-724) - -### Events Extension Pattern - -The events extension provides database-agnostic pub/sub via two layers: - -**Backends** handle communication (LISTEN/NOTIFY, Oracle AQ, table polling): - -```python -# Backend selection via extension_config (NOT driver_features) -config = AsyncpgConfig( - connection_config={"dsn": "postgresql://..."}, - extension_config={ - "events": { - "backend": "listen_notify_durable", # Backend selection here - "queue_table": "event_queue_custom", - "lease_seconds": 60, - } - } -) -``` - -Available backends: -- `listen_notify`: Real-time PostgreSQL LISTEN/NOTIFY (ephemeral, no migrations) -- `table_queue`: Durable table-backed queue with retries (all adapters) -- `listen_notify_durable`: Hybrid combining both (PostgreSQL only) -- `advanced_queue`: Oracle Advanced Queueing - -PostgreSQL adapters (asyncpg, psycopg, psqlpy) default to `listen_notify`. -All other adapters default to `table_queue`. - -**Stores** generate adapter-specific DDL using a hook-based pattern: - -```python -# In sqlspec/adapters/{adapter}/events/store.py -class AdapterEventQueueStore(BaseEventQueueStore[AdapterConfig]): - __slots__ = () - - # REQUIRED: Return (payload_type, metadata_type, timestamp_type) - def _column_types(self) -> tuple[str, str, str]: - return "JSONB", "JSONB", "TIMESTAMPTZ" # PostgreSQL - - # OPTIONAL hooks for dialect variations: - def _string_type(self, length: int) -> str: - return f"VARCHAR({length})" # Override for STRING(N), VARCHAR2(N), etc. - - def _integer_type(self) -> str: - return "INTEGER" # Override for INT64, NUMBER(10), etc. - - def _timestamp_default(self) -> str: - return "CURRENT_TIMESTAMP" # Override for CURRENT_TIMESTAMP(6), SYSTIMESTAMP, etc. - - def _primary_key_syntax(self) -> str: - return "" # Override for " PRIMARY KEY (event_id)" if PK must be inline - - def _table_clause(self) -> str: - return "" # Override for " CLUSTER BY ..." or " INMEMORY ..." -``` - -Most adapters only override `_column_types()`. Complex dialects may override -`_build_create_table_sql()` directly (Oracle PL/SQL, BigQuery CLUSTER BY, Spanner no-DEFAULT). - -**Backend factory pattern** for native backends: - -```python -# In sqlspec/adapters/{adapter}/events/backend.py -def create_event_backend( - config: "AdapterConfig", backend_name: str, extension_settings: dict[str, Any] -) -> AdapterEventsBackend | None: - if backend_name == "listen_notify": - return AdapterEventsBackend(config) - if backend_name == "listen_notify_durable": - queue = TableEventQueue(config, **extension_settings) - return AdapterHybridEventsBackend(config, QueueEventBackend(queue)) - return None # Falls back to table_queue -``` - -**Key principles:** - -- Backends implement `publish_async`, `dequeue_async`, `ack_async` (and sync variants) -- Stores inherit from `BaseEventQueueStore` and override `_column_types()` -- Use `extension_config["events"]["backend"]` for backend selection, other keys for settings -- Always support `table_queue` fallback for databases without native pub/sub -- Separate DDL execution for databases without transactional DDL (Spanner, BigQuery) - -**Auto-migration inclusion:** - -Extensions with migration support are automatically included in -`migration_config["include_extensions"]` based on their settings: - -- **litestar**: Only when ``session_table`` is set (``True`` or custom name) -- **adk**: When any adk settings are present -- **events**: When any events settings are present - -Use `exclude_extensions` to opt out: - -```python -# Auto-includes events migrations when extension_config["events"] is set -config = AsyncpgConfig( - connection_config={"dsn": "postgresql://..."}, - extension_config={"events": {"backend": "table_queue"}}, -) - -# Litestar with session storage - auto-includes migrations -config = AsyncpgConfig( - connection_config={"dsn": "postgresql://..."}, - extension_config={"litestar": {"session_table": True}}, # or "my_sessions" -) - -# Litestar for DI only - no migrations needed -config = AsyncpgConfig( - connection_config={"dsn": "postgresql://..."}, - extension_config={"litestar": {"session_key": "db"}}, # No session_table = no migrations -) - -# Exclude events migrations when using ephemeral backend -config = AsyncpgConfig( - connection_config={"dsn": "postgresql://..."}, - migration_config={"exclude_extensions": ["events"]}, # Skip queue table migration - extension_config={"events": {"backend": "listen_notify"}}, -) -``` - -**Reference implementation:** `sqlspec/extensions/events/`, `sqlspec/adapters/*/events/` +- Inherit from `SQLSpecError` in `sqlspec/exceptions.py` +- Use `wrap_exceptions` context manager +- Two-tier: graceful skip (DEBUG) for expected, hard errors for malformed ## Collaboration Guidelines diff --git a/docs/guides/architecture/custom-sqlglot-dialects.md b/docs/guides/architecture/custom-sqlglot-dialects.md index b3fd20628..887b1116d 100644 --- a/docs/guides/architecture/custom-sqlglot-dialects.md +++ b/docs/guides/architecture/custom-sqlglot-dialects.md @@ -1,5 +1,7 @@ # Custom SQLglot Dialects + + This guide explains how SQLSpec implements and uses custom SQLglot dialects for database-specific SQL features. ## Overview diff --git a/docs/guides/builder/explain.md b/docs/guides/builder/explain.md new file mode 100644 index 000000000..fd55565cb --- /dev/null +++ b/docs/guides/builder/explain.md @@ -0,0 +1,530 @@ +# EXPLAIN Plan Builder Guide + +## Overview + +The EXPLAIN statement allows you to analyze how a database will execute a query, showing the query execution plan, estimated costs, and optimization decisions. SQLSpec provides a fluent builder API for constructing EXPLAIN statements with dialect-aware SQL generation that automatically adapts to your target database. + +## When to Use EXPLAIN + +**Use EXPLAIN when**: +- Debugging slow queries to understand why they are slow +- Verifying that indexes are being used correctly +- Understanding join strategies and table scan behavior +- Comparing different query approaches before choosing one +- Learning how your database processes SQL + +**Use EXPLAIN ANALYZE when**: +- You need actual runtime statistics (not just estimates) +- Verifying that estimated costs match reality +- Profiling query performance with real data + +**Warning**: EXPLAIN ANALYZE executes the query, which can modify data (for INSERT/UPDATE/DELETE) and incur costs (BigQuery). Use with caution in production. + +## Database Compatibility + +| Database | EXPLAIN | EXPLAIN ANALYZE | Formats | Notes | +|------------|---------|-----------------|---------|-------| +| PostgreSQL | Yes | Yes | TEXT, JSON, XML, YAML | Full options support (BUFFERS, TIMING, etc.) | +| MySQL | Yes | Yes | TRADITIONAL, JSON, TREE | ANALYZE always uses TREE format | +| SQLite | Yes (QUERY PLAN) | No | TEXT | Output format varies by SQLite version | +| DuckDB | Yes | Yes | TEXT, JSON | JSON format via (FORMAT JSON) | +| Oracle | Yes | No | TEXT | Two-step process (EXPLAIN PLAN FOR + DBMS_XPLAN) | +| BigQuery | Yes | Yes | TEXT | ANALYZE executes query and incurs costs | +| Spanner | N/A | N/A | N/A | Uses API-based query mode parameter | + +## Basic Usage + +### SQL Class Method + +The simplest way to get an explain plan for an existing SQL statement: + +```python +from sqlspec.core import SQL + +stmt = SQL("SELECT * FROM users WHERE status = :status", {"status": "active"}) +explain_stmt = stmt.explain() + +# Execute with any adapter +async with config.provide_session() as session: + result = await session.execute(explain_stmt) + for row in result.data: + print(row) +``` + +### SQLFactory Method + +Use `sql.explain()` to wrap any statement: + +```python +from sqlspec.builder import sql + +# Explain a raw SQL string +explain = sql.explain("SELECT * FROM users WHERE id = :id", id=1) +explain_sql = explain.build() + +# Explain a query builder +query = sql.select("*").from_("users").where_eq("id", 1) +explain = sql.explain(query, analyze=True) +explain_sql = explain.build() +``` + +### QueryBuilder Integration + +All query builders (Select, Insert, Update, Delete, Merge) have an `.explain()` method: + +```python +from sqlspec.builder import Select + +query = ( + Select("*", dialect="postgres") + .from_("users") + .where("status = :status", status="active") + .order_by("created_at DESC") +) + +# Get explain plan +explain = query.explain(analyze=True, format="json") +explain_sql = explain.build() + +# Execute +async with config.provide_session() as session: + result = await session.execute(explain_sql) + plan = result.data[0] # JSON plan data +``` + +## Fluent API + +The `Explain` builder provides a fluent interface for configuring EXPLAIN options: + +```python +from sqlspec.builder import Explain + +explain = ( + Explain("SELECT * FROM users", dialect="postgres") + .analyze() # Execute and show actual runtime stats + .verbose() # Show additional information + .format("json") # Output format + .buffers() # Show buffer usage (PostgreSQL) + .timing() # Show timing information (PostgreSQL) + .costs() # Show cost estimates + .build() +) +``` + +### Available Methods + +| Method | Description | Databases | +|--------|-------------|-----------| +| `.analyze()` | Execute query and show actual statistics | PostgreSQL, MySQL, DuckDB, BigQuery | +| `.verbose()` | Show additional information | PostgreSQL | +| `.format(fmt)` | Set output format | PostgreSQL, MySQL, DuckDB | +| `.costs()` | Include cost estimates | PostgreSQL | +| `.buffers()` | Include buffer usage statistics | PostgreSQL (requires ANALYZE) | +| `.timing()` | Include actual timing | PostgreSQL (requires ANALYZE) | +| `.summary()` | Include summary information | PostgreSQL | +| `.memory()` | Include memory usage | PostgreSQL 17+ | +| `.settings()` | Include configuration parameters | PostgreSQL 12+ | +| `.wal()` | Include WAL usage | PostgreSQL 13+ (requires ANALYZE) | +| `.generic_plan()` | Generate plan ignoring parameter values | PostgreSQL 16+ | + +## Dialect-Specific Examples + +### PostgreSQL + +PostgreSQL has the most comprehensive EXPLAIN support: + +```python +from sqlspec.builder import Explain, Select +from sqlspec.core.explain import ExplainFormat + +# Basic explain +query = Select("*", dialect="postgres").from_("users") +explain = query.explain().build() +# EXPLAIN SELECT * FROM users + +# Full analysis with all options +explain = ( + Explain("SELECT * FROM orders WHERE total > 100", dialect="postgres") + .analyze() + .verbose() + .buffers() + .timing() + .format(ExplainFormat.JSON) + .build() +) +# EXPLAIN (ANALYZE, VERBOSE, BUFFERS TRUE, TIMING TRUE, FORMAT JSON) SELECT ... + +# PostgreSQL 16+ generic plan +explain = ( + Explain("SELECT * FROM users WHERE id = $1", dialect="postgres") + .generic_plan() + .build() +) +# EXPLAIN (GENERIC_PLAN TRUE) SELECT * FROM users WHERE id = $1 +``` + +### MySQL + +MySQL supports FORMAT options and ANALYZE (which always uses TREE format): + +```python +from sqlspec.builder import Explain + +# JSON format for structured output +explain = ( + Explain("SELECT * FROM users", dialect="mysql") + .format("json") + .build() +) +# EXPLAIN FORMAT = JSON SELECT * FROM users + +# TREE format for hierarchical view +explain = ( + Explain("SELECT * FROM users", dialect="mysql") + .format("tree") + .build() +) +# EXPLAIN FORMAT = TREE SELECT * FROM users + +# ANALYZE (always uses TREE, ignores format option) +explain = ( + Explain("SELECT * FROM users", dialect="mysql") + .analyze() + .build() +) +# EXPLAIN ANALYZE SELECT * FROM users +``` + +### SQLite + +SQLite only supports EXPLAIN QUERY PLAN (no ANALYZE or format options): + +```python +from sqlspec.builder import Explain + +explain = ( + Explain("SELECT * FROM users WHERE id = ?", dialect="sqlite") + .build() +) +# EXPLAIN QUERY PLAN SELECT * FROM users WHERE id = ? + +# Note: analyze and format options are ignored for SQLite +explain = ( + Explain("SELECT * FROM users", dialect="sqlite") + .analyze() # Ignored + .format("json") # Ignored + .build() +) +# EXPLAIN QUERY PLAN SELECT * FROM users +``` + +**Warning**: SQLite's EXPLAIN QUERY PLAN output format may change between versions. Avoid parsing it programmatically. + +### DuckDB + +DuckDB supports ANALYZE and JSON format: + +```python +from sqlspec.builder import Explain + +# Basic explain +explain = Explain("SELECT * FROM users", dialect="duckdb").build() +# EXPLAIN SELECT * FROM users + +# With ANALYZE +explain = ( + Explain("SELECT * FROM users", dialect="duckdb") + .analyze() + .build() +) +# EXPLAIN ANALYZE SELECT * FROM users + +# JSON format +explain = ( + Explain("SELECT * FROM users", dialect="duckdb") + .format("json") + .build() +) +# EXPLAIN (FORMAT JSON) SELECT * FROM users +``` + +### Oracle + +Oracle uses a two-step process - the builder generates the first step: + +```python +from sqlspec.builder import Explain + +explain = ( + Explain("SELECT * FROM users", dialect="oracle") + .build() +) +# EXPLAIN PLAN FOR SELECT * FROM users + +# To view the plan, execute a second query: +# SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY()) +``` + +Note: Oracle's EXPLAIN PLAN does not execute the query - it just stores the plan in PLAN_TABLE. + +### BigQuery + +```python +from sqlspec.builder import Explain + +# Estimated plan (no cost) +explain = Explain("SELECT * FROM users", dialect="bigquery").build() +# EXPLAIN SELECT * FROM users + +# Actual execution statistics (incurs query costs!) +explain = ( + Explain("SELECT * FROM users", dialect="bigquery") + .analyze() + .build() +) +# EXPLAIN ANALYZE SELECT * FROM users +``` + +**Warning**: EXPLAIN ANALYZE in BigQuery executes the query and incurs costs. + +## ExplainOptions and ExplainFormat + +For programmatic configuration, use `ExplainOptions` and `ExplainFormat`: + +```python +from sqlspec.core.explain import ExplainOptions, ExplainFormat +from sqlspec.builder import Explain + +# Create reusable options +options = ExplainOptions( + analyze=True, + verbose=True, + format=ExplainFormat.JSON, + buffers=True, + timing=True, +) + +# Apply to multiple queries +explain1 = Explain("SELECT * FROM users", dialect="postgres", options=options).build() +explain2 = Explain("SELECT * FROM orders", dialect="postgres", options=options).build() + +# Copy and modify options +debug_options = options.copy(summary=True, memory=True) +``` + +### ExplainFormat Enum + +```python +from sqlspec.core.explain import ExplainFormat + +ExplainFormat.TEXT # Default text output +ExplainFormat.JSON # JSON (PostgreSQL, MySQL, DuckDB) +ExplainFormat.XML # XML (PostgreSQL only) +ExplainFormat.YAML # YAML (PostgreSQL only) +ExplainFormat.TREE # Tree format (MySQL, DuckDB) +ExplainFormat.TRADITIONAL # Tabular format (MySQL) +``` + +## Working with Parameters + +EXPLAIN preserves parameters from the underlying statement: + +```python +from sqlspec.core import SQL +from sqlspec.builder import Explain + +# Parameters are preserved +stmt = SQL("SELECT * FROM users WHERE id = :id", {"id": 42}) +explain = Explain(stmt).build() + +print(explain.named_parameters) # {'id': 42} + +# Execute with parameters +async with config.provide_session() as session: + result = await session.execute(explain) +``` + +## Best Practices + +### 1. Use JSON Format for Programmatic Analysis + +JSON output is easier to parse and analyze programmatically: + +```python +explain = query.explain(format="json").build() +result = await session.execute(explain) + +# Parse JSON plan +import json +plan = json.loads(result.data[0]["QUERY PLAN"]) +print(f"Estimated cost: {plan[0]['Plan']['Total Cost']}") +``` + +### 2. Always Test with Representative Data + +Query plans depend on table statistics. Test with production-like data volumes: + +```python +# Good: Test with realistic data +explain = query.explain(analyze=True).build() +result = await session.execute(explain) + +# Check actual vs estimated rows +# Significant differences indicate stale statistics +``` + +### 3. Use ANALYZE Sparingly in Production + +EXPLAIN ANALYZE executes the query, which can: +- Modify data (INSERT/UPDATE/DELETE) +- Hold locks +- Consume resources +- Incur costs (BigQuery) + +```python +# Development: Use ANALYZE freely +explain = query.explain(analyze=True).build() + +# Production: Use plain EXPLAIN +explain = query.explain().build() # Estimates only, no execution +``` + +### 4. Compare Query Approaches + +Use EXPLAIN to compare different query strategies: + +```python +# Approach 1: Subquery +query1 = sql.select("*").from_("orders").where("user_id IN (SELECT id FROM users WHERE active)") + +# Approach 2: JOIN +query2 = sql.select("orders.*").from_("orders").join("users", "users.id = orders.user_id").where("users.active") + +# Compare plans +explain1 = query1.explain().build() +explain2 = query2.explain().build() + +# Execute both and compare costs +``` + +### 5. Check Index Usage + +Verify indexes are being used: + +```python +explain = ( + sql.select("*") + .from_("orders") + .where("customer_id = :id", id=123) + .explain(format="json") + .build() +) + +result = await session.execute(explain) +plan = json.loads(result.data[0]["QUERY PLAN"]) + +# Look for "Index Scan" vs "Seq Scan" in plan +``` + +## Common Patterns + +### Debugging Slow Queries + +```python +async def debug_slow_query(session, query): + """Analyze why a query is slow.""" + explain = ( + Explain(query, dialect="postgres") + .analyze() + .buffers() + .timing() + .format("json") + .build() + ) + + result = await session.execute(explain) + plan = json.loads(result.data[0]["QUERY PLAN"])[0] + + print(f"Total Time: {plan['Execution Time']}ms") + print(f"Planning Time: {plan['Planning Time']}ms") + + # Find slow nodes + def find_slow_nodes(node, threshold_ms=100): + if node.get("Actual Total Time", 0) > threshold_ms: + yield node + for child in node.get("Plans", []): + yield from find_slow_nodes(child, threshold_ms) + + for slow_node in find_slow_nodes(plan["Plan"]): + print(f"Slow: {slow_node['Node Type']} - {slow_node['Actual Total Time']}ms") +``` + +### Query Plan Comparison + +```python +async def compare_plans(session, query1, query2, dialect="postgres"): + """Compare execution plans of two queries.""" + explain1 = Explain(query1, dialect=dialect).format("json").build() + explain2 = Explain(query2, dialect=dialect).format("json").build() + + result1 = await session.execute(explain1) + result2 = await session.execute(explain2) + + plan1 = json.loads(result1.data[0]["QUERY PLAN"])[0]["Plan"] + plan2 = json.loads(result2.data[0]["QUERY PLAN"])[0]["Plan"] + + print(f"Query 1 - Cost: {plan1['Total Cost']}, Rows: {plan1['Plan Rows']}") + print(f"Query 2 - Cost: {plan2['Total Cost']}, Rows: {plan2['Plan Rows']}") + + if plan1["Total Cost"] < plan2["Total Cost"]: + print("Query 1 is more efficient") + else: + print("Query 2 is more efficient") +``` + +## Troubleshooting + +### Error: "Unknown format option" + +**Problem**: Database doesn't support the requested format. + +**Solution**: Check database compatibility table above. Use TEXT for maximum compatibility. + +### Error: "EXPLAIN ANALYZE not supported" + +**Problem**: SQLite and Oracle don't support EXPLAIN ANALYZE. + +**Solution**: For SQLite, use EXPLAIN QUERY PLAN without ANALYZE. For Oracle, use DBMS_XPLAN.DISPLAY_CURSOR after executing the query. + +### Plan Shows Sequential Scan Instead of Index Scan + +**Problem**: Database is not using an index. + +**Possible causes**: +1. Table is small (sequential scan is faster) +2. No suitable index exists +3. Statistics are out of date +4. Query predicate doesn't match index + +**Solution**: +```sql +-- Update statistics (PostgreSQL) +ANALYZE table_name; + +-- Check available indexes +\di table_name -- psql + +-- Force index usage (testing only) +SET enable_seqscan = off; +``` + +## See Also + +- [PostgreSQL EXPLAIN Documentation](https://www.postgresql.org/docs/current/sql-explain.html) +- [MySQL EXPLAIN Statement](https://dev.mysql.com/doc/refman/8.0/en/explain.html) +- [SQLite EXPLAIN QUERY PLAN](https://www.sqlite.org/eqp.html) +- [DuckDB EXPLAIN](https://duckdb.org/docs/guides/meta/explain) +- [Oracle DBMS_XPLAN](https://docs.oracle.com/database/121/ARPLS/d_xplan.htm) +- [BigQuery Query Plans](https://cloud.google.com/bigquery/docs/query-plan-explanation) +- [Query Builder Guide](./merge.md) +- [Performance Tuning Guide](../performance/sqlglot.md) diff --git a/docs/guides/development/code-standards.md b/docs/guides/development/code-standards.md index b679116db..b73dca3f1 100644 --- a/docs/guides/development/code-standards.md +++ b/docs/guides/development/code-standards.md @@ -104,6 +104,8 @@ def use_numpy_feature(): - **Preferred**: 30-50 lines for most functions - Split longer functions into smaller helpers + + ### Anti-Patterns to Avoid ```python @@ -310,6 +312,8 @@ def parse_content(content: str, source: str) -> "dict[str, Result]": - Build locally: `make docs` before submission - Use reStructuredText (.rst) and Markdown (.md via MyST) + + ## Mypyc-Compatible Class Pattern For data-holding classes in `sqlspec/core/` and `sqlspec/driver/`: @@ -344,6 +348,8 @@ class MyMetadata: - Explicit `__init__`, `__repr__`, `__eq__`, `__hash__` for full control - Avoid `@dataclass` decorators in mypyc-compiled modules + + ## Testing Standards ### Function-Based Tests Only diff --git a/docs/guides/development/implementation-patterns.md b/docs/guides/development/implementation-patterns.md index 1a8ab4d8e..221ce1c1a 100644 --- a/docs/guides/development/implementation-patterns.md +++ b/docs/guides/development/implementation-patterns.md @@ -2,6 +2,8 @@ This guide documents the key implementation patterns used throughout SQLSpec. Reference these patterns when implementing new adapters, features, or framework extensions. + + ## Protocol Abstract Methods Pattern When adding methods that need to support both sync and async configurations: @@ -67,6 +69,8 @@ class NoPoolAsyncConfig(DatabaseConfigProtocol): - Async base classes implement with `async def` and `await` - All 4 base classes inherit working methods automatically + + ## driver_features Pattern ### TypedDict Definition (MANDATORY) @@ -128,6 +132,8 @@ class AdapterConfig(AsyncDatabaseConfig): - Feature changes database behavior in non-obvious ways - Feature is experimental + + ## Type Handler Pattern ### Module Structure @@ -231,6 +237,8 @@ async def _init_connection(self, connection: "Connection") -> None: register_handlers(connection) ``` + + ## Framework Extension Pattern ### Middleware-Based (Starlette/FastAPI) @@ -513,3 +521,114 @@ from sqlspec.utils.sync_tools import await_ sync_add = await_(async_add) result = sync_add(5, 3) # Returns 8, using portal internally ``` + + + +## EXPLAIN Builder Pattern + +The EXPLAIN builder demonstrates dialect-aware SQL generation for a non-standard statement type. + +### Dialect Dispatch Pattern + +```python +POSTGRES_DIALECTS = frozenset({"postgres", "postgresql", "redshift"}) +MYSQL_DIALECTS = frozenset({"mysql", "mariadb"}) + +def build_explain_sql( + statement_sql: str, + options: "ExplainOptions", + dialect: "DialectType | None" = None, +) -> str: + """Build dialect-specific EXPLAIN SQL.""" + dialect_name = _normalize_dialect_name(dialect) + + if dialect_name in POSTGRES_DIALECTS: + return _build_postgres_explain(statement_sql, options) + if dialect_name in MYSQL_DIALECTS: + return _build_mysql_explain(statement_sql, options) + # ... more dialects + + return _build_generic_explain(statement_sql, options) +``` + +### Options Class with Immutable Copy + +```python +@mypyc_attr(allow_interpreted_subclasses=False) +class ExplainOptions: + """Mypyc-compatible options class with immutable copy pattern.""" + + __slots__ = ("analyze", "verbose", "format", ...) + + def __init__(self, analyze: bool = False, ...) -> None: + self.analyze = analyze + # ... set all slots + + def copy(self, analyze: "bool | None" = None, ...) -> "ExplainOptions": + """Create a copy with optional modifications.""" + return ExplainOptions( + analyze=analyze if analyze is not None else self.analyze, + # ... copy all fields with overrides + ) +``` + +### Mixin for QueryBuilder Integration + +```python +class ExplainMixin: + """Add .explain() to QueryBuilder subclasses.""" + + __slots__ = () + + dialect: "DialectType | None" + + def explain( + self, + analyze: bool = False, + verbose: bool = False, + format: "ExplainFormat | str | None" = None, + ) -> "Explain": + """Create an EXPLAIN builder for this query.""" + options = ExplainOptions( + analyze=analyze, + verbose=verbose, + format=ExplainFormat(format.lower()) if isinstance(format, str) else format, + ) + return Explain(self, dialect=self.dialect, options=options) +``` + +### Statement Resolution Pattern + +```python +def _resolve_statement_sql( + self, statement: "str | exp.Expression | SQL | SQLBuilderProtocol" +) -> str: + """Resolve different statement types to SQL string.""" + if isinstance(statement, str): + return statement + + if isinstance(statement, SQL): + self._parameters.update(statement.named_parameters) + return statement.raw_sql + + if is_expression(statement): + return statement.sql(dialect=self._dialect) + + if has_parameter_builder(statement): + safe_query = statement.build(dialect=self._dialect) + if safe_query.parameters: + self._parameters.update(safe_query.parameters) + return str(safe_query.sql) + + if has_expression_and_sql(statement): + return statement.sql + + msg = f"Cannot resolve statement to SQL: {type(statement).__name__}" + raise SQLBuilderError(msg) +``` + +**Key principles:** +- Use frozenset for dialect groupings (hashable, immutable) +- Normalize dialect names to lowercase for consistent matching +- Preserve parameters from underlying statements +- Use type guards instead of `isinstance()` for protocol checks diff --git a/docs/guides/events/database-event-channels.md b/docs/guides/events/database-event-channels.md index 892ea2072..84e58f6e0 100644 --- a/docs/guides/events/database-event-channels.md +++ b/docs/guides/events/database-event-channels.md @@ -277,6 +277,8 @@ channel.shutdown() config.close_pool() ``` + + ## Architecture The events extension consists of two layers that work together: diff --git a/docs/guides/quick-reference/quick-reference.md b/docs/guides/quick-reference/quick-reference.md index 7177e9906..d2faa9dc2 100644 --- a/docs/guides/quick-reference/quick-reference.md +++ b/docs/guides/quick-reference/quick-reference.md @@ -750,3 +750,87 @@ def test_special_handling(driver): 4. **Special Handling Hook** - _try_special_handling for database-specific operations 5. **Proper Abstraction** - Clear separation between compilation and execution 6. **Test Coverage** - Comprehensive testing of all execution paths + +## EXPLAIN Plan Support + +SQLSpec provides dialect-aware EXPLAIN statement generation for query plan analysis. + +### Basic Usage + +```python +from sqlspec.builder import sql, Explain +from sqlspec.core import SQL + +# Method 1: SQL class method +stmt = SQL("SELECT * FROM users WHERE status = :status", {"status": "active"}) +explain_stmt = stmt.explain() + +# Method 2: SQLFactory method +explain = sql.explain("SELECT * FROM users", analyze=True) + +# Method 3: QueryBuilder integration +query = sql.select("*").from_("users").where_eq("id", 1) +explain = query.explain(analyze=True, format="json").build() + +# Method 4: Direct Explain builder +explain = ( + Explain("SELECT * FROM users", dialect="postgres") + .analyze() + .format("json") + .buffers() + .build() +) +``` + +### Dialect-Specific Output + +| Database | Basic | With ANALYZE | Formats | +|----------|-------|--------------|---------| +| PostgreSQL | `EXPLAIN stmt` | `EXPLAIN (ANALYZE) stmt` | TEXT, JSON, XML, YAML | +| MySQL | `EXPLAIN stmt` | `EXPLAIN ANALYZE stmt` | TRADITIONAL, JSON, TREE | +| SQLite | `EXPLAIN QUERY PLAN stmt` | N/A | TEXT only | +| DuckDB | `EXPLAIN stmt` | `EXPLAIN ANALYZE stmt` | TEXT, JSON | +| Oracle | `EXPLAIN PLAN FOR stmt` | N/A | TEXT (via DBMS_XPLAN) | +| BigQuery | `EXPLAIN stmt` | `EXPLAIN ANALYZE stmt` | TEXT | + +### PostgreSQL Full Options + +```python +explain = ( + Explain("SELECT * FROM users", dialect="postgres") + .analyze() # Execute for actual statistics + .verbose() # Additional information + .costs() # Show cost estimates + .buffers() # Buffer usage (requires ANALYZE) + .timing() # Actual timing (requires ANALYZE) + .summary() # Summary information + .memory() # Memory usage (PostgreSQL 17+) + .settings() # Configuration parameters (PostgreSQL 12+) + .wal() # WAL usage (PostgreSQL 13+) + .generic_plan() # Ignore parameter values (PostgreSQL 16+) + .format("json") # Output format + .build() +) +``` + +### ExplainOptions Class + +```python +from sqlspec.core.explain import ExplainOptions, ExplainFormat + +# Create reusable options +options = ExplainOptions( + analyze=True, + verbose=True, + format=ExplainFormat.JSON, + buffers=True, +) + +# Apply to Explain builder +explain = Explain(query, dialect="postgres", options=options).build() + +# Copy and modify +debug_options = options.copy(timing=True, summary=True) +``` + +See the [EXPLAIN Plan Guide](../builder/explain.md) for comprehensive examples. diff --git a/sqlspec/builder/__init__.py b/sqlspec/builder/__init__.py index 8293087b6..35288f5e9 100644 --- a/sqlspec/builder/__init__.py +++ b/sqlspec/builder/__init__.py @@ -33,6 +33,7 @@ UpdateSetClauseMixin, UpdateTableClauseMixin, ) +from sqlspec.builder._explain import Explain, ExplainMixin from sqlspec.builder._expression_wrappers import ( AggregateExpression, ConversionExpression, @@ -101,6 +102,8 @@ "DropSchema", "DropTable", "DropView", + "Explain", + "ExplainMixin", "FunctionColumn", "FunctionExpression", "HavingClauseMixin", diff --git a/sqlspec/builder/_delete.py b/sqlspec/builder/_delete.py index 2b4ea9928..1bc8fbe44 100644 --- a/sqlspec/builder/_delete.py +++ b/sqlspec/builder/_delete.py @@ -13,6 +13,7 @@ from sqlspec.builder._base import QueryBuilder, SafeQuery from sqlspec.builder._dml import DeleteFromClauseMixin +from sqlspec.builder._explain import ExplainMixin from sqlspec.builder._select import ReturningClauseMixin, WhereClauseMixin from sqlspec.core import SQLResult from sqlspec.exceptions import SQLBuilderError @@ -20,7 +21,7 @@ __all__ = ("Delete",) -class Delete(QueryBuilder, WhereClauseMixin, ReturningClauseMixin, DeleteFromClauseMixin): +class Delete(QueryBuilder, WhereClauseMixin, ReturningClauseMixin, DeleteFromClauseMixin, ExplainMixin): """Builder for DELETE statements. Constructs SQL DELETE statements with parameter binding and validation. diff --git a/sqlspec/builder/_explain.py b/sqlspec/builder/_explain.py new file mode 100644 index 000000000..01ffee9e6 --- /dev/null +++ b/sqlspec/builder/_explain.py @@ -0,0 +1,560 @@ +"""EXPLAIN statement builder. + +Provides a fluent interface for building EXPLAIN statements with +dialect-aware SQL generation. +""" + +from typing import TYPE_CHECKING, Any + +from typing_extensions import Self + +from sqlspec.core import SQL +from sqlspec.core.explain import ExplainFormat, ExplainOptions +from sqlspec.exceptions import SQLBuilderError +from sqlspec.utils.type_guards import has_expression_and_sql, has_parameter_builder, is_expression + +if TYPE_CHECKING: + from sqlglot import exp + from sqlglot.dialects.dialect import DialectType + + from sqlspec.protocols import SQLBuilderProtocol + + +__all__ = ("Explain", "ExplainMixin") + + +POSTGRES_DIALECTS = frozenset({"postgres", "postgresql", "redshift"}) +MYSQL_DIALECTS = frozenset({"mysql", "mariadb"}) +SQLITE_DIALECTS = frozenset({"sqlite"}) +DUCKDB_DIALECTS = frozenset({"duckdb"}) +ORACLE_DIALECTS = frozenset({"oracle"}) +BIGQUERY_DIALECTS = frozenset({"bigquery"}) +SPANNER_DIALECTS = frozenset({"spanner"}) + + +def _normalize_dialect_name(dialect: "DialectType | None") -> str | None: + """Normalize dialect to lowercase string. + + Args: + dialect: Dialect type, string, or None + + Returns: + Lowercase string representation of dialect or None + """ + if dialect is None: + return None + if isinstance(dialect, str): + return dialect.lower() + return dialect.__class__.__name__.lower() + + +def _build_postgres_explain(statement_sql: str, options: "ExplainOptions") -> str: + """Build PostgreSQL EXPLAIN statement. + + PostgreSQL uses the syntax: EXPLAIN (OPTIONS) statement + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration + + Returns: + Complete EXPLAIN SQL string + """ + option_parts: list[str] = [] + + if options.analyze: + option_parts.append("ANALYZE") + if options.verbose: + option_parts.append("VERBOSE") + if options.costs is not None: + option_parts.append(f"COSTS {'TRUE' if options.costs else 'FALSE'}") + if options.buffers is not None: + option_parts.append(f"BUFFERS {'TRUE' if options.buffers else 'FALSE'}") + if options.timing is not None: + option_parts.append(f"TIMING {'TRUE' if options.timing else 'FALSE'}") + if options.summary is not None: + option_parts.append(f"SUMMARY {'TRUE' if options.summary else 'FALSE'}") + if options.memory is not None: + option_parts.append(f"MEMORY {'TRUE' if options.memory else 'FALSE'}") + if options.settings is not None: + option_parts.append(f"SETTINGS {'TRUE' if options.settings else 'FALSE'}") + if options.wal is not None: + option_parts.append(f"WAL {'TRUE' if options.wal else 'FALSE'}") + if options.generic_plan is not None: + option_parts.append(f"GENERIC_PLAN {'TRUE' if options.generic_plan else 'FALSE'}") + if options.format is not None: + option_parts.append(f"FORMAT {options.format.value.upper()}") + + if option_parts: + options_str = ", ".join(option_parts) + return f"EXPLAIN ({options_str}) {statement_sql}" + return f"EXPLAIN {statement_sql}" + + +def _build_mysql_explain(statement_sql: str, options: "ExplainOptions") -> str: + """Build MySQL EXPLAIN statement. + + MySQL uses: + - EXPLAIN [FORMAT = TRADITIONAL|JSON|TREE] statement + - EXPLAIN ANALYZE statement (always TREE format) + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration + + Returns: + Complete EXPLAIN SQL string + """ + if options.analyze: + return f"EXPLAIN ANALYZE {statement_sql}" + + if options.format is not None: + format_map = { + ExplainFormat.JSON: "JSON", + ExplainFormat.TREE: "TREE", + ExplainFormat.TRADITIONAL: "TRADITIONAL", + ExplainFormat.TEXT: "TRADITIONAL", + } + fmt = format_map.get(options.format, "TRADITIONAL") + return f"EXPLAIN FORMAT = {fmt} {statement_sql}" + + return f"EXPLAIN {statement_sql}" + + +def _build_sqlite_explain(statement_sql: str, options: "ExplainOptions") -> str: + """Build SQLite EXPLAIN statement. + + SQLite only supports EXPLAIN QUERY PLAN (no additional options). + Raw EXPLAIN returns virtual machine opcodes which is rarely useful. + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration (mostly ignored for SQLite) + + Returns: + Complete EXPLAIN SQL string + """ + return f"EXPLAIN QUERY PLAN {statement_sql}" + + +def _build_duckdb_explain(statement_sql: str, options: "ExplainOptions") -> str: + """Build DuckDB EXPLAIN statement. + + DuckDB supports: + - EXPLAIN statement + - EXPLAIN ANALYZE statement + - EXPLAIN (FORMAT JSON) statement + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration + + Returns: + Complete EXPLAIN SQL string + """ + if options.analyze: + return f"EXPLAIN ANALYZE {statement_sql}" + + if options.format is not None and options.format == ExplainFormat.JSON: + return f"EXPLAIN (FORMAT JSON) {statement_sql}" + + return f"EXPLAIN {statement_sql}" + + +def _build_oracle_explain(statement_sql: str, options: "ExplainOptions") -> str: + """Build Oracle EXPLAIN statement. + + Oracle requires a two-step process: + 1. EXPLAIN PLAN FOR statement + 2. SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY()) + + This function returns only the first step. The driver must handle + executing both statements. + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration (mostly ignored for Oracle) + + Returns: + EXPLAIN PLAN FOR SQL string + """ + return f"EXPLAIN PLAN FOR {statement_sql}" + + +def _build_bigquery_explain(statement_sql: str, options: "ExplainOptions") -> str: + """Build BigQuery EXPLAIN statement. + + BigQuery supports: + - EXPLAIN statement + - EXPLAIN ANALYZE statement (incurs query execution costs!) + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration + + Returns: + Complete EXPLAIN SQL string + """ + if options.analyze: + return f"EXPLAIN ANALYZE {statement_sql}" + return f"EXPLAIN {statement_sql}" + + +def _build_generic_explain(statement_sql: str, options: "ExplainOptions") -> str: + """Build generic EXPLAIN statement for unknown dialects. + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration + + Returns: + Complete EXPLAIN SQL string + """ + if options.analyze: + return f"EXPLAIN ANALYZE {statement_sql}" + return f"EXPLAIN {statement_sql}" + + +def build_explain_sql(statement_sql: str, options: "ExplainOptions", dialect: "DialectType | None" = None) -> str: + """Build dialect-specific EXPLAIN SQL. + + Args: + statement_sql: The SQL statement to explain + options: ExplainOptions configuration + dialect: Target SQL dialect + + Returns: + Complete EXPLAIN SQL string for the target dialect + """ + dialect_name = _normalize_dialect_name(dialect) + + if dialect_name in POSTGRES_DIALECTS: + return _build_postgres_explain(statement_sql, options) + if dialect_name in MYSQL_DIALECTS: + return _build_mysql_explain(statement_sql, options) + if dialect_name in SQLITE_DIALECTS: + return _build_sqlite_explain(statement_sql, options) + if dialect_name in DUCKDB_DIALECTS: + return _build_duckdb_explain(statement_sql, options) + if dialect_name in ORACLE_DIALECTS: + return _build_oracle_explain(statement_sql, options) + if dialect_name in BIGQUERY_DIALECTS: + return _build_bigquery_explain(statement_sql, options) + + return _build_generic_explain(statement_sql, options) + + +class Explain: + """Builder for EXPLAIN statements with dialect-aware rendering. + + Provides a fluent API for constructing EXPLAIN statements with + various options that are translated to dialect-specific syntax. + + Examples: + Basic usage: + explain = Explain("SELECT * FROM users").build() + + With options: + explain = ( + Explain("SELECT * FROM users", dialect="postgres") + .analyze() + .format("json") + .buffers() + .build() + ) + + From QueryBuilder: + explain = ( + Explain(select_builder, dialect="postgres") + .analyze() + .verbose() + .build() + ) + """ + + __slots__ = ("_dialect", "_options", "_parameters", "_statement", "_statement_sql") + + def __init__( + self, + statement: "str | exp.Expression | SQL | SQLBuilderProtocol", + dialect: "DialectType | None" = None, + options: "ExplainOptions | None" = None, + ) -> None: + """Initialize ExplainBuilder. + + Args: + statement: SQL statement to explain (string, expression, SQL object, or builder) + dialect: Target SQL dialect + options: Initial ExplainOptions (or None for defaults) + """ + self._dialect = dialect + self._options = options if options is not None else ExplainOptions() + self._statement = statement + self._parameters: dict[str, Any] = {} + + self._statement_sql = self._resolve_statement_sql(statement) + + def _resolve_statement_sql(self, statement: "str | exp.Expression | SQL | SQLBuilderProtocol") -> str: + """Resolve statement to SQL string. + + Args: + statement: The statement to resolve + + Returns: + SQL string representation of the statement + """ + if isinstance(statement, str): + return statement + + if isinstance(statement, SQL): + self._parameters.update(statement.named_parameters) + return statement.raw_sql + + if is_expression(statement): + dialect_str = _normalize_dialect_name(self._dialect) + return statement.sql(dialect=dialect_str) + + if has_parameter_builder(statement): + safe_query = statement.build(dialect=self._dialect) + if safe_query.parameters: + self._parameters.update(safe_query.parameters) + return str(safe_query.sql) + + if has_expression_and_sql(statement): + return statement.sql + + msg = f"Cannot resolve statement to SQL: {type(statement).__name__}" + raise SQLBuilderError(msg) + + def analyze(self, enabled: bool = True) -> Self: + """Enable ANALYZE option (execute statement for real statistics). + + Args: + enabled: Whether to enable ANALYZE + + Returns: + Self for method chaining + """ + self._options = self._options.copy(analyze=enabled) + return self + + def verbose(self, enabled: bool = True) -> Self: + """Enable VERBOSE option (show additional information). + + Args: + enabled: Whether to enable VERBOSE + + Returns: + Self for method chaining + """ + self._options = self._options.copy(verbose=enabled) + return self + + def format(self, fmt: "ExplainFormat | str") -> Self: + """Set output format. + + Args: + fmt: Output format (TEXT, JSON, XML, YAML, TREE, TRADITIONAL) + + Returns: + Self for method chaining + """ + if isinstance(fmt, str): + fmt = ExplainFormat(fmt.lower()) + self._options = self._options.copy(format=fmt) + return self + + def costs(self, enabled: bool = True) -> Self: + """Enable COSTS option (show estimated costs). + + Args: + enabled: Whether to show costs + + Returns: + Self for method chaining + """ + self._options = self._options.copy(costs=enabled) + return self + + def buffers(self, enabled: bool = True) -> Self: + """Enable BUFFERS option (show buffer usage). + + Args: + enabled: Whether to show buffer usage + + Returns: + Self for method chaining + """ + self._options = self._options.copy(buffers=enabled) + return self + + def timing(self, enabled: bool = True) -> Self: + """Enable TIMING option (show actual timing). + + Args: + enabled: Whether to show timing + + Returns: + Self for method chaining + """ + self._options = self._options.copy(timing=enabled) + return self + + def summary(self, enabled: bool = True) -> Self: + """Enable SUMMARY option (show summary information). + + Args: + enabled: Whether to show summary + + Returns: + Self for method chaining + """ + self._options = self._options.copy(summary=enabled) + return self + + def memory(self, enabled: bool = True) -> Self: + """Enable MEMORY option (show memory usage, PostgreSQL 17+). + + Args: + enabled: Whether to show memory usage + + Returns: + Self for method chaining + """ + self._options = self._options.copy(memory=enabled) + return self + + def settings(self, enabled: bool = True) -> Self: + """Enable SETTINGS option (show configuration parameters, PostgreSQL 12+). + + Args: + enabled: Whether to show settings + + Returns: + Self for method chaining + """ + self._options = self._options.copy(settings=enabled) + return self + + def wal(self, enabled: bool = True) -> Self: + """Enable WAL option (show WAL usage, PostgreSQL 13+). + + Args: + enabled: Whether to show WAL usage + + Returns: + Self for method chaining + """ + self._options = self._options.copy(wal=enabled) + return self + + def generic_plan(self, enabled: bool = True) -> Self: + """Enable GENERIC_PLAN option (ignore parameter values, PostgreSQL 16+). + + Args: + enabled: Whether to use generic plan + + Returns: + Self for method chaining + """ + self._options = self._options.copy(generic_plan=enabled) + return self + + def with_options(self, options: "ExplainOptions") -> Self: + """Replace all options with the provided ExplainOptions. + + Args: + options: New options to use + + Returns: + Self for method chaining + """ + self._options = options + return self + + @property + def options(self) -> "ExplainOptions": + """Get current ExplainOptions.""" + return self._options + + @property + def dialect(self) -> "DialectType | None": + """Get current dialect.""" + return self._dialect + + @property + def parameters(self) -> dict[str, Any]: + """Get parameters from the underlying statement.""" + return self._parameters.copy() + + def build(self, dialect: "DialectType | None" = None) -> "SQL": + """Build the EXPLAIN statement as a SQL object. + + Args: + dialect: Optional dialect override + + Returns: + SQL object containing the EXPLAIN statement + """ + target_dialect = dialect or self._dialect + explain_sql = build_explain_sql(self._statement_sql, self._options, target_dialect) + + if self._parameters: + return SQL(explain_sql, self._parameters) + return SQL(explain_sql) + + def to_sql(self, dialect: "DialectType | None" = None) -> str: + """Build and return just the SQL string. + + Args: + dialect: Optional dialect override + + Returns: + EXPLAIN SQL string + """ + target_dialect = dialect or self._dialect + return build_explain_sql(self._statement_sql, self._options, target_dialect) + + def __repr__(self) -> str: + """String representation.""" + return f"Explain({self._statement_sql!r}, dialect={self._dialect!r}, options={self._options!r})" + + +class ExplainMixin: + """Mixin to add .explain() method to QueryBuilder subclasses. + + This mixin can be added to any QueryBuilder subclass to provide + EXPLAIN plan functionality. + + Examples: + class Select(QueryBuilder, ExplainMixin): + pass + + query = Select().select("*").from_("users") + explain = query.explain().analyze().format("json").build() + """ + + __slots__ = () + + dialect: "DialectType | None" + + def explain( + self, analyze: bool = False, verbose: bool = False, format: "ExplainFormat | str | None" = None + ) -> "Explain": + """Create an EXPLAIN builder for this query. + + Args: + analyze: Execute the statement for real statistics + verbose: Show additional information + format: Output format (TEXT, JSON, XML, YAML, TREE) + + Returns: + Explain builder for further configuration + """ + fmt = None + if format is not None: + fmt = ExplainFormat(format.lower()) if isinstance(format, str) else format + + options = ExplainOptions(analyze=analyze, verbose=verbose, format=fmt) + + return Explain(self, dialect=self.dialect, options=options) # type: ignore[arg-type] diff --git a/sqlspec/builder/_factory.py b/sqlspec/builder/_factory.py index 2d72944d8..567240140 100644 --- a/sqlspec/builder/_factory.py +++ b/sqlspec/builder/_factory.py @@ -31,6 +31,7 @@ Truncate, ) from sqlspec.builder._delete import Delete +from sqlspec.builder._explain import Explain from sqlspec.builder._expression_wrappers import ( AggregateExpression, ConversionExpression, @@ -52,6 +53,8 @@ from collections.abc import Mapping, Sequence from sqlspec.builder._expression_wrappers import ExpressionWrapper + from sqlspec.core.explain import ExplainFormat + from sqlspec.protocols import SQLBuilderProtocol __all__ = ( @@ -70,6 +73,7 @@ "DropSchema", "DropTable", "DropView", + "Explain", "Insert", "Merge", "RenameTable", @@ -379,6 +383,69 @@ def merge(self, table_or_sql: str | None = None, dialect: DialectType = None) -> return Merge(table_or_sql, dialect=builder_dialect) if table_or_sql else Merge(dialect=builder_dialect) + def explain( + self, + statement: "str | exp.Expression | SQL | SQLBuilderProtocol", + *, + analyze: bool = False, + verbose: bool = False, + format: "ExplainFormat | str | None" = None, + dialect: DialectType = None, + ) -> "Explain": + """Create an EXPLAIN builder for a SQL statement. + + Wraps any SQL statement in an EXPLAIN clause with dialect-aware + syntax generation. + + Args: + statement: SQL statement to explain (string, expression, SQL object, or builder) + analyze: Execute the statement and show actual runtime statistics + verbose: Show additional information + format: Output format (TEXT, JSON, XML, YAML, TREE, TRADITIONAL) + dialect: Optional SQL dialect override + + Returns: + Explain builder for further configuration + + Examples: + Basic EXPLAIN: + plan = sql.explain("SELECT * FROM users").build() + + With options: + plan = ( + sql.explain("SELECT * FROM users", analyze=True, format="json") + .buffers() + .timing() + .build() + ) + + From QueryBuilder: + query = sql.select("*").from_("users").where("id = :id", id=1) + plan = sql.explain(query, analyze=True).build() + + Chained configuration: + plan = ( + sql.explain(sql.select("*").from_("large_table")) + .analyze() + .format("json") + .buffers() + .timing() + .build() + ) + """ + from sqlspec.core.explain import ExplainFormat as ExplainFmt + from sqlspec.core.explain import ExplainOptions + + builder_dialect = dialect or self.dialect + + fmt = None + if format is not None: + fmt = ExplainFmt(format.lower()) if isinstance(format, str) else format + + options = ExplainOptions(analyze=analyze, verbose=verbose, format=fmt) + + return Explain(statement, dialect=builder_dialect, options=options) + @property def merge_(self) -> "Merge": """Create a new MERGE builder (property shorthand). diff --git a/sqlspec/builder/_insert.py b/sqlspec/builder/_insert.py index d71266f22..73340bda5 100644 --- a/sqlspec/builder/_insert.py +++ b/sqlspec/builder/_insert.py @@ -11,6 +11,7 @@ from sqlspec.builder._base import QueryBuilder from sqlspec.builder._dml import InsertFromSelectMixin, InsertIntoClauseMixin, InsertValuesMixin +from sqlspec.builder._explain import ExplainMixin from sqlspec.builder._parsing_utils import extract_sql_object_expression from sqlspec.builder._select import ReturningClauseMixin from sqlspec.core import SQLResult @@ -28,7 +29,9 @@ ERR_MSG_EXPRESSION_NOT_INITIALIZED: Final[str] = "Internal error: base expression not initialized." -class Insert(QueryBuilder, ReturningClauseMixin, InsertValuesMixin, InsertFromSelectMixin, InsertIntoClauseMixin): +class Insert( + QueryBuilder, ReturningClauseMixin, InsertValuesMixin, InsertFromSelectMixin, InsertIntoClauseMixin, ExplainMixin +): """Builder for INSERT statements. Constructs SQL INSERT queries with parameter binding and validation. diff --git a/sqlspec/builder/_merge.py b/sqlspec/builder/_merge.py index 7f1898287..6d3e4a0bf 100644 --- a/sqlspec/builder/_merge.py +++ b/sqlspec/builder/_merge.py @@ -18,6 +18,7 @@ from typing_extensions import Self from sqlspec.builder._base import QueryBuilder +from sqlspec.builder._explain import ExplainMixin from sqlspec.builder._parsing_utils import extract_sql_object_expression from sqlspec.builder._select import is_explicitly_quoted from sqlspec.core import SQLResult @@ -724,6 +725,7 @@ class Merge( MergeNotMatchedClauseMixin, MergeIntoClauseMixin, MergeNotMatchedBySourceClauseMixin, + ExplainMixin, ): """Builder for MERGE statements. diff --git a/sqlspec/builder/_select.py b/sqlspec/builder/_select.py index d0a2b735e..9cccb34a8 100644 --- a/sqlspec/builder/_select.py +++ b/sqlspec/builder/_select.py @@ -14,6 +14,7 @@ from typing_extensions import Self from sqlspec.builder._base import QueryBuilder, SafeQuery +from sqlspec.builder._explain import ExplainMixin from sqlspec.builder._join import JoinClauseMixin from sqlspec.builder._parsing_utils import ( extract_column_name, @@ -1339,6 +1340,7 @@ class Select( CommonTableExpressionMixin, PivotClauseMixin, UnpivotClauseMixin, + ExplainMixin, ): """Builder for SELECT queries. diff --git a/sqlspec/builder/_update.py b/sqlspec/builder/_update.py index 8bffeceb4..9e7cc9fa7 100644 --- a/sqlspec/builder/_update.py +++ b/sqlspec/builder/_update.py @@ -11,6 +11,7 @@ from sqlspec.builder._base import QueryBuilder, SafeQuery from sqlspec.builder._dml import UpdateFromClauseMixin, UpdateSetClauseMixin, UpdateTableClauseMixin +from sqlspec.builder._explain import ExplainMixin from sqlspec.builder._join import build_join_clause from sqlspec.builder._select import ReturningClauseMixin, WhereClauseMixin from sqlspec.core import SQLResult @@ -32,6 +33,7 @@ class Update( UpdateSetClauseMixin, UpdateFromClauseMixin, UpdateTableClauseMixin, + ExplainMixin, ): """Builder for UPDATE statements. diff --git a/sqlspec/core/__init__.py b/sqlspec/core/__init__.py index 6af419da5..95791db50 100644 --- a/sqlspec/core/__init__.py +++ b/sqlspec/core/__init__.py @@ -122,6 +122,7 @@ is_copy_operation, is_copy_to_operation, ) +from sqlspec.core.explain import ExplainFormat, ExplainOptions from sqlspec.core.filters import ( AnyCollectionFilter, BeforeAfterFilter, @@ -220,6 +221,8 @@ "CachedStatement", "CompiledSQL", "DriverParameterProfile", + "ExplainFormat", + "ExplainOptions", "FilterTypeT", "FilterTypes", "FiltersView", diff --git a/sqlspec/core/explain.py b/sqlspec/core/explain.py new file mode 100644 index 000000000..acd141fa4 --- /dev/null +++ b/sqlspec/core/explain.py @@ -0,0 +1,275 @@ +"""EXPLAIN plan options and format definitions. + +Provides types for configuring EXPLAIN statement generation across different +database dialects. +""" + +from enum import Enum +from typing import Any, Final + +from mypy_extensions import mypyc_attr + +__all__ = ("ExplainFormat", "ExplainOptions") + + +EXPLAIN_OPTIONS_SLOTS: Final = ( + "analyze", + "verbose", + "format", + "costs", + "buffers", + "timing", + "summary", + "memory", + "settings", + "wal", + "generic_plan", +) + + +class ExplainFormat(str, Enum): + """Output formats for EXPLAIN statements. + + Different databases support different output formats: + - TEXT: All databases (default) + - JSON: PostgreSQL, MySQL (8.0+), DuckDB + - XML: PostgreSQL + - YAML: PostgreSQL + - TREE: MySQL (8.0+), DuckDB + - TRADITIONAL: MySQL (tabular output) + """ + + TEXT = "text" + JSON = "json" + XML = "xml" + YAML = "yaml" + TREE = "tree" + TRADITIONAL = "traditional" + + +@mypyc_attr(allow_interpreted_subclasses=False) +class ExplainOptions: + """Configuration options for EXPLAIN statements. + + Encapsulates all possible EXPLAIN options across different database dialects. + Not all options are supported by all databases - the builder will select + appropriate options based on the target dialect. + + PostgreSQL Options: + analyze: Execute the statement and show actual runtime statistics + verbose: Show additional information (schema-qualified names, etc.) + costs: Include estimated costs (default True in PostgreSQL) + buffers: Include buffer usage information (requires ANALYZE) + timing: Include actual timing information (requires ANALYZE) + summary: Include summary information after the query plan + memory: Include memory usage information (PostgreSQL 17+) + settings: Include configuration parameter information (PostgreSQL 12+) + wal: Include WAL usage information (requires ANALYZE, PostgreSQL 13+) + generic_plan: Generate a generic plan ignoring parameter values (PostgreSQL 16+) + format: Output format (TEXT, JSON, XML, YAML) + + MySQL Options: + analyze: Execute and show actual statistics (always uses TREE format) + format: Output format (TRADITIONAL, JSON, TREE) + + SQLite: + Only supports EXPLAIN QUERY PLAN (no additional options) + + DuckDB: + analyze: Execute and show actual statistics + format: Output format (TEXT, JSON) + + Oracle: + Uses EXPLAIN PLAN FOR + DBMS_XPLAN.DISPLAY (special handling) + + BigQuery: + analyze: Execute and show actual statistics (incurs costs!) + """ + + __slots__ = EXPLAIN_OPTIONS_SLOTS + + def __init__( + self, + analyze: bool = False, + verbose: bool = False, + format: "ExplainFormat | str | None" = None, + costs: bool | None = None, + buffers: bool | None = None, + timing: bool | None = None, + summary: bool | None = None, + memory: bool | None = None, + settings: bool | None = None, + wal: bool | None = None, + generic_plan: bool | None = None, + ) -> None: + """Initialize ExplainOptions. + + Args: + analyze: Execute the statement and show actual runtime statistics + verbose: Show additional information + format: Output format (TEXT, JSON, XML, YAML, TREE, TRADITIONAL) + costs: Include estimated costs + buffers: Include buffer usage information + timing: Include actual timing information + summary: Include summary information + memory: Include memory usage information + settings: Include configuration parameter information + wal: Include WAL usage information + generic_plan: Generate a generic plan ignoring parameter values + """ + self.analyze = analyze + self.verbose = verbose + self.costs = costs + self.buffers = buffers + self.timing = timing + self.summary = summary + self.memory = memory + self.settings = settings + self.wal = wal + self.generic_plan = generic_plan + + if format is not None: + if isinstance(format, ExplainFormat): + self.format: ExplainFormat | None = format + else: + self.format = ExplainFormat(format.lower()) + else: + self.format = None + + def __repr__(self) -> str: + """String representation of ExplainOptions.""" + parts = [] + if self.analyze: + parts.append("analyze=True") + if self.verbose: + parts.append("verbose=True") + if self.format is not None: + parts.append(f"format={self.format.value!r}") + if self.costs is not None: + parts.append(f"costs={self.costs}") + if self.buffers is not None: + parts.append(f"buffers={self.buffers}") + if self.timing is not None: + parts.append(f"timing={self.timing}") + if self.summary is not None: + parts.append(f"summary={self.summary}") + if self.memory is not None: + parts.append(f"memory={self.memory}") + if self.settings is not None: + parts.append(f"settings={self.settings}") + if self.wal is not None: + parts.append(f"wal={self.wal}") + if self.generic_plan is not None: + parts.append(f"generic_plan={self.generic_plan}") + return f"ExplainOptions({', '.join(parts)})" + + def __eq__(self, other: object) -> bool: + """Equality comparison.""" + if not isinstance(other, ExplainOptions): + return False + return ( + self.analyze == other.analyze + and self.verbose == other.verbose + and self.format == other.format + and self.costs == other.costs + and self.buffers == other.buffers + and self.timing == other.timing + and self.summary == other.summary + and self.memory == other.memory + and self.settings == other.settings + and self.wal == other.wal + and self.generic_plan == other.generic_plan + ) + + def __hash__(self) -> int: + """Hash computation.""" + return hash(( + self.analyze, + self.verbose, + self.format, + self.costs, + self.buffers, + self.timing, + self.summary, + self.memory, + self.settings, + self.wal, + self.generic_plan, + )) + + def copy( + self, + analyze: "bool | None" = None, + verbose: "bool | None" = None, + format: "ExplainFormat | str | None" = None, + costs: "bool | None" = None, + buffers: "bool | None" = None, + timing: "bool | None" = None, + summary: "bool | None" = None, + memory: "bool | None" = None, + settings: "bool | None" = None, + wal: "bool | None" = None, + generic_plan: "bool | None" = None, + ) -> "ExplainOptions": + """Create a copy with modifications. + + Args: + analyze: Override analyze setting + verbose: Override verbose setting + format: Override format setting + costs: Override costs setting + buffers: Override buffers setting + timing: Override timing setting + summary: Override summary setting + memory: Override memory setting + settings: Override settings setting + wal: Override wal setting + generic_plan: Override generic_plan setting + + Returns: + New ExplainOptions instance with modifications applied + """ + return ExplainOptions( + analyze=analyze if analyze is not None else self.analyze, + verbose=verbose if verbose is not None else self.verbose, + format=format if format is not None else self.format, + costs=costs if costs is not None else self.costs, + buffers=buffers if buffers is not None else self.buffers, + timing=timing if timing is not None else self.timing, + summary=summary if summary is not None else self.summary, + memory=memory if memory is not None else self.memory, + settings=settings if settings is not None else self.settings, + wal=wal if wal is not None else self.wal, + generic_plan=generic_plan if generic_plan is not None else self.generic_plan, + ) + + def to_dict(self) -> dict[str, Any]: + """Convert options to dictionary (only non-None values). + + Returns: + Dictionary of option names to values + """ + result: dict[str, Any] = {} + if self.analyze: + result["analyze"] = True + if self.verbose: + result["verbose"] = True + if self.format is not None: + result["format"] = self.format.value.upper() + if self.costs is not None: + result["costs"] = self.costs + if self.buffers is not None: + result["buffers"] = self.buffers + if self.timing is not None: + result["timing"] = self.timing + if self.summary is not None: + result["summary"] = self.summary + if self.memory is not None: + result["memory"] = self.memory + if self.settings is not None: + result["settings"] = self.settings + if self.wal is not None: + result["wal"] = self.wal + if self.generic_plan is not None: + result["generic_plan"] = self.generic_plan + return result diff --git a/sqlspec/core/statement.py b/sqlspec/core/statement.py index 6f47237a4..9dcb2986e 100644 --- a/sqlspec/core/statement.py +++ b/sqlspec/core/statement.py @@ -593,6 +593,40 @@ def where(self, condition: "str | exp.Expression") -> "SQL": new_sql._filters = self._filters.copy() return new_sql + def explain(self, analyze: bool = False, verbose: bool = False, format: "str | None" = None) -> "SQL": + """Create an EXPLAIN statement for this SQL. + + Wraps the current SQL statement in an EXPLAIN clause with + dialect-aware syntax generation. + + Args: + analyze: Execute the statement and show actual runtime statistics + verbose: Show additional information + format: Output format (TEXT, JSON, XML, YAML, TREE, TRADITIONAL) + + Returns: + New SQL instance containing the EXPLAIN statement + + Examples: + Basic EXPLAIN: + stmt = SQL("SELECT * FROM users") + explain_stmt = stmt.explain() + + With options: + explain_stmt = stmt.explain(analyze=True, format="json") + """ + from sqlspec.builder._explain import Explain + from sqlspec.core.explain import ExplainFormat, ExplainOptions + + fmt = None + if format is not None: + fmt = ExplainFormat(format.lower()) + + options = ExplainOptions(analyze=analyze, verbose=verbose, format=fmt) + + explain_builder = Explain(self, dialect=self._dialect, options=options) + return explain_builder.build() + def __hash__(self) -> int: """Hash value computation.""" if self._hash is None: diff --git a/tests/integration/test_adapters/test_adbc/test_explain.py b/tests/integration/test_adapters/test_adbc/test_explain.py new file mode 100644 index 000000000..c7d1b67f3 --- /dev/null +++ b/tests/integration/test_adapters/test_adbc/test_explain.py @@ -0,0 +1,149 @@ +"""Integration tests for EXPLAIN plan support with adbc adapter. + +Note: ADBC uses COPY protocol which wraps queries in COPY (query) TO STDOUT, +making EXPLAIN statements incompatible. These tests are skipped. +""" + +from collections.abc import Generator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.adbc import AdbcConfig, AdbcDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = [ + pytest.mark.xdist_group("postgres"), + pytest.mark.skip(reason="ADBC uses COPY protocol which is incompatible with EXPLAIN statements"), +] + + +@pytest.fixture +def adbc_session(adbc_postgres_config: AdbcConfig) -> Generator[AdbcDriver, None, None]: + """Create an adbc session with test table. + + ADBC typically connects to PostgreSQL for these tests. + """ + with adbc_postgres_config.provide_session() as session: + session.execute_script("DROP TABLE IF EXISTS explain_test") + session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + value INTEGER DEFAULT 0 + ) + """ + ) + session.commit() + yield session + + try: + session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +def test_explain_basic_select(adbc_session: AdbcDriver) -> None: + """Test basic EXPLAIN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres") + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + assert len(result.data) > 0 + + +def test_explain_analyze(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN ANALYZE on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze() + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_format_json(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN with JSON format.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").format("json") + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_verbose(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN VERBOSE.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").verbose() + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_full_options(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN with multiple options.""" + explain_stmt = ( + Explain("SELECT * FROM explain_test", dialect="postgres").analyze().verbose().buffers().timing().format("json") + ) + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_query_builder(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain(analyze=True) + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_factory(adbc_session: AdbcDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", analyze=True, dialect="postgres") + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_object(adbc_session: AdbcDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain(analyze=True) + result = adbc_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_insert(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN on INSERT statement.""" + explain_stmt = Explain("INSERT INTO explain_test (name, value) VALUES ('test', 1)", dialect="postgres").analyze() + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_update(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN on UPDATE statement.""" + explain_stmt = Explain("UPDATE explain_test SET value = 100 WHERE id = 1", dialect="postgres").analyze() + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_delete(adbc_session: AdbcDriver) -> None: + """Test EXPLAIN on DELETE statement.""" + explain_stmt = Explain("DELETE FROM explain_test WHERE id = 1", dialect="postgres").analyze() + result = adbc_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_aiosqlite/test_explain.py b/tests/integration/test_adapters/test_aiosqlite/test_explain.py new file mode 100644 index 000000000..d50486f8e --- /dev/null +++ b/tests/integration/test_adapters/test_aiosqlite/test_explain.py @@ -0,0 +1,109 @@ +"""Integration tests for EXPLAIN plan support with aiosqlite adapter.""" + +from collections.abc import AsyncGenerator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.aiosqlite import AiosqliteConfig, AiosqliteDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = [pytest.mark.xdist_group("sqlite"), pytest.mark.anyio] + + +@pytest.fixture +async def aiosqlite_explain_session(aiosqlite_config: AiosqliteConfig) -> AsyncGenerator[AiosqliteDriver, None]: + """Create an aiosqlite session with test table.""" + async with aiosqlite_config.provide_session() as session: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + await session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + value INTEGER DEFAULT 0 + ) + """ + ) + await session.commit() + yield session + + try: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +async def test_explain_query_plan_select(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="sqlite") + result = await aiosqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_query_plan_with_where(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN with WHERE clause.""" + explain_stmt = Explain("SELECT * FROM explain_test WHERE id = 1", dialect="sqlite") + result = await aiosqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_query_builder(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain() + result = await aiosqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_factory(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", dialect="sqlite") + result = await aiosqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_object(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain() + result = await aiosqlite_explain_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_insert(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on INSERT statement.""" + explain_stmt = Explain("INSERT INTO explain_test (name, value) VALUES ('test', 1)", dialect="sqlite") + result = await aiosqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_update(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on UPDATE statement.""" + explain_stmt = Explain("UPDATE explain_test SET value = 100 WHERE id = 1", dialect="sqlite") + result = await aiosqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_delete(aiosqlite_explain_session: AiosqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on DELETE statement.""" + explain_stmt = Explain("DELETE FROM explain_test WHERE id = 1", dialect="sqlite") + result = await aiosqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_asyncmy/test_explain.py b/tests/integration/test_adapters/test_asyncmy/test_explain.py new file mode 100644 index 000000000..7ec7b9710 --- /dev/null +++ b/tests/integration/test_adapters/test_asyncmy/test_explain.py @@ -0,0 +1,138 @@ +"""Integration tests for EXPLAIN plan support with asyncmy adapter (MySQL).""" + +from collections.abc import AsyncGenerator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.asyncmy import AsyncmyConfig, AsyncmyDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = [pytest.mark.xdist_group("mysql"), pytest.mark.anyio] + + +@pytest.fixture +async def asyncmy_session(asyncmy_config: AsyncmyConfig) -> AsyncGenerator[AsyncmyDriver, None]: + """Create an asyncmy session with test table.""" + async with asyncmy_config.provide_session() as session: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + await session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + value INT DEFAULT 0 + ) + """ + ) + yield session + + try: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +async def test_explain_basic_select(asyncmy_session: AsyncmyDriver) -> None: + """Test basic EXPLAIN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="mysql") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_analyze(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN ANALYZE on SELECT statement (MySQL 8.0+).""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="mysql").analyze() + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_format_json(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN FORMAT = JSON.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="mysql").format("json") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_format_tree(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN FORMAT = TREE (MySQL 8.0+).""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="mysql").format("tree") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_format_traditional(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN FORMAT = TRADITIONAL.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="mysql").format("traditional") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_query_builder(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin. + + Note: Uses raw SQL since query builder without dialect produces PostgreSQL-style SQL. + """ + explain_stmt = Explain("SELECT * FROM explain_test WHERE id > 0", dialect="mysql").analyze() + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_factory(asyncmy_session: AsyncmyDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", dialect="mysql") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_object(asyncmy_session: AsyncmyDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + # Use Explain directly with dialect since SQL uses default dialect + explain_stmt = Explain(stmt.sql, dialect="mysql") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_insert(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN on INSERT statement.""" + explain_stmt = Explain("INSERT INTO explain_test (name, value) VALUES ('test', 1)", dialect="mysql") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_update(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN on UPDATE statement.""" + explain_stmt = Explain("UPDATE explain_test SET value = 100 WHERE id = 1", dialect="mysql") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_delete(asyncmy_session: AsyncmyDriver) -> None: + """Test EXPLAIN on DELETE statement.""" + explain_stmt = Explain("DELETE FROM explain_test WHERE id = 1", dialect="mysql") + result = await asyncmy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_asyncpg/test_explain.py b/tests/integration/test_adapters/test_asyncpg/test_explain.py new file mode 100644 index 000000000..5843a6906 --- /dev/null +++ b/tests/integration/test_adapters/test_asyncpg/test_explain.py @@ -0,0 +1,119 @@ +"""Integration tests for EXPLAIN plan support with asyncpg adapter.""" + +from collections.abc import AsyncGenerator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.asyncpg import AsyncpgConfig, AsyncpgDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = [pytest.mark.xdist_group("postgres"), pytest.mark.anyio] + + +@pytest.fixture +async def asyncpg_session(asyncpg_config: AsyncpgConfig) -> AsyncGenerator[AsyncpgDriver, None]: + """Create an asyncpg session with test table.""" + async with asyncpg_config.provide_session() as session: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + await session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + value INTEGER DEFAULT 0 + ) + """ + ) + yield session + + try: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +async def test_explain_basic_select(asyncpg_session: AsyncpgDriver) -> None: + """Test basic EXPLAIN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres") + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_analyze(asyncpg_session: AsyncpgDriver) -> None: + """Test EXPLAIN ANALYZE on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze() + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_with_format_json(asyncpg_session: AsyncpgDriver) -> None: + """Test EXPLAIN with JSON format.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").format("json") + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_analyze_with_buffers(asyncpg_session: AsyncpgDriver) -> None: + """Test EXPLAIN ANALYZE with BUFFERS option.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze().buffers() + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_verbose(asyncpg_session: AsyncpgDriver) -> None: + """Test EXPLAIN VERBOSE.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").verbose() + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_full_options(asyncpg_session: AsyncpgDriver) -> None: + """Test EXPLAIN with multiple options.""" + explain_stmt = ( + Explain("SELECT * FROM explain_test", dialect="postgres").analyze().verbose().buffers().timing().format("json") + ) + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_query_builder(asyncpg_session: AsyncpgDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain(analyze=True) + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_factory(asyncpg_session: AsyncpgDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", analyze=True, dialect="postgres") + result = await asyncpg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_object(asyncpg_session: AsyncpgDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain(analyze=True) + result = await asyncpg_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_bigquery/test_explain.py b/tests/integration/test_adapters/test_bigquery/test_explain.py new file mode 100644 index 000000000..d337a6da5 --- /dev/null +++ b/tests/integration/test_adapters/test_bigquery/test_explain.py @@ -0,0 +1,105 @@ +"""Integration tests for EXPLAIN plan support with bigquery adapter. + +Note: BigQuery emulator does not support EXPLAIN statements. +These tests are skipped in CI but can be run against real BigQuery. +""" + +from collections.abc import Generator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.bigquery import BigQueryConfig, BigQueryDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = [ + pytest.mark.xdist_group("bigquery"), + pytest.mark.skip(reason="BigQuery emulator does not support EXPLAIN statements"), +] + + +@pytest.fixture +def bigquery_explain_session( + bigquery_config: BigQueryConfig, table_schema_prefix: str +) -> Generator[BigQueryDriver, None, None]: + """Create a bigquery session with test table.""" + with bigquery_config.provide_session() as session: + try: + session.execute_script(f"DROP TABLE IF EXISTS {table_schema_prefix}.explain_test") + except Exception: + pass + + session.execute_script( + f""" + CREATE TABLE IF NOT EXISTS {table_schema_prefix}.explain_test ( + id INT64, + name STRING NOT NULL, + value INT64 + ) + """ + ) + yield session + + try: + session.execute_script(f"DROP TABLE IF EXISTS {table_schema_prefix}.explain_test") + except Exception: + pass + + +def test_explain_basic_select(bigquery_explain_session: BigQueryDriver, table_schema_prefix: str) -> None: + """Test basic EXPLAIN on SELECT statement.""" + explain_stmt = Explain(f"SELECT * FROM {table_schema_prefix}.explain_test", dialect="bigquery") + result = bigquery_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_where(bigquery_explain_session: BigQueryDriver, table_schema_prefix: str) -> None: + """Test EXPLAIN with WHERE clause.""" + explain_stmt = Explain(f"SELECT * FROM {table_schema_prefix}.explain_test WHERE id = 1", dialect="bigquery") + result = bigquery_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_query_builder(bigquery_explain_session: BigQueryDriver, table_schema_prefix: str) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_(f"{table_schema_prefix}.explain_test").where("id > :id", id=0) + explain_stmt = query.explain() + result = bigquery_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_factory(bigquery_explain_session: BigQueryDriver, table_schema_prefix: str) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain(f"SELECT * FROM {table_schema_prefix}.explain_test", dialect="bigquery") + result = bigquery_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_object(bigquery_explain_session: BigQueryDriver, table_schema_prefix: str) -> None: + """Test SQL.explain() method.""" + stmt = SQL(f"SELECT * FROM {table_schema_prefix}.explain_test") + explain_stmt = stmt.explain() + result = bigquery_explain_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_aggregate(bigquery_explain_session: BigQueryDriver, table_schema_prefix: str) -> None: + """Test EXPLAIN with aggregate functions.""" + explain_stmt = Explain( + f"SELECT COUNT(*), SUM(value) FROM {table_schema_prefix}.explain_test GROUP BY name", dialect="bigquery" + ) + result = bigquery_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_duckdb/test_explain.py b/tests/integration/test_adapters/test_duckdb/test_explain.py new file mode 100644 index 000000000..c3da7b270 --- /dev/null +++ b/tests/integration/test_adapters/test_duckdb/test_explain.py @@ -0,0 +1,160 @@ +"""Integration tests for EXPLAIN plan support with duckdb adapter.""" + +from collections.abc import Generator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.duckdb import DuckDBConfig, DuckDBDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = pytest.mark.xdist_group("duckdb") + + +@pytest.fixture +def duckdb_session(duckdb_basic_config: DuckDBConfig) -> Generator[DuckDBDriver, None, None]: + """Create a duckdb session with test table.""" + with duckdb_basic_config.provide_session() as session: + session.execute_script("DROP TABLE IF EXISTS explain_test") + session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id INTEGER PRIMARY KEY, + name VARCHAR NOT NULL, + value INTEGER DEFAULT 0 + ) + """ + ) + yield session + + try: + session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +def test_explain_basic_select(duckdb_session: DuckDBDriver) -> None: + """Test basic EXPLAIN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="duckdb") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_analyze(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN ANALYZE on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="duckdb").analyze() + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_format_json(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN (FORMAT JSON).""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="duckdb").format("json") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_where(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN with WHERE clause.""" + explain_stmt = Explain("SELECT * FROM explain_test WHERE id = 1", dialect="duckdb") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_join(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN with JOIN.""" + duckdb_session.execute_script("DROP TABLE IF EXISTS explain_test2") + duckdb_session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test2 ( + id INTEGER PRIMARY KEY, + test_id INTEGER, + data VARCHAR + ) + """ + ) + + try: + explain_stmt = Explain( + "SELECT * FROM explain_test e JOIN explain_test2 e2 ON e.id = e2.test_id", dialect="duckdb" + ) + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + finally: + duckdb_session.execute_script("DROP TABLE IF EXISTS explain_test2") + + +def test_explain_from_query_builder(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain() + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_factory(duckdb_session: DuckDBDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", dialect="duckdb") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_object(duckdb_session: DuckDBDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain() + result = duckdb_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_insert(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN on INSERT statement.""" + explain_stmt = Explain("INSERT INTO explain_test (id, name, value) VALUES (1, 'test', 1)", dialect="duckdb") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_update(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN on UPDATE statement.""" + explain_stmt = Explain("UPDATE explain_test SET value = 100 WHERE id = 1", dialect="duckdb") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_delete(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN on DELETE statement.""" + explain_stmt = Explain("DELETE FROM explain_test WHERE id = 1", dialect="duckdb") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_aggregate(duckdb_session: DuckDBDriver) -> None: + """Test EXPLAIN with aggregate functions.""" + explain_stmt = Explain("SELECT COUNT(*), SUM(value) FROM explain_test GROUP BY name", dialect="duckdb") + result = duckdb_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_oracledb/test_explain.py b/tests/integration/test_adapters/test_oracledb/test_explain.py new file mode 100644 index 000000000..4d6b95f66 --- /dev/null +++ b/tests/integration/test_adapters/test_oracledb/test_explain.py @@ -0,0 +1,131 @@ +"""Integration tests for EXPLAIN plan support with oracledb adapter.""" + +from collections.abc import Generator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.oracledb import OracleSyncConfig, OracleSyncDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = pytest.mark.xdist_group("oracle") + + +@pytest.fixture +def oracle_explain_session(oracle_sync_config: OracleSyncConfig) -> Generator[OracleSyncDriver, None, None]: + """Create an oracledb session with test table.""" + with oracle_sync_config.provide_session() as session: + try: + session.execute_script("DROP TABLE explain_test") + except Exception: + pass + + session.execute_script( + """ + CREATE TABLE explain_test ( + id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR2(255) NOT NULL, + value NUMBER DEFAULT 0 + ) + """ + ) + session.commit() + yield session + + try: + session.execute_script("DROP TABLE explain_test") + except Exception: + pass + + +def test_explain_plan_for_select(oracle_explain_session: OracleSyncDriver) -> None: + """Test EXPLAIN PLAN FOR on SELECT statement. + + Oracle uses a two-step process: + 1. EXPLAIN PLAN FOR + 2. SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY()) + + This test only verifies step 1 executes without error. + """ + explain_stmt = Explain("SELECT * FROM explain_test", dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_explain_plan_with_where(oracle_explain_session: OracleSyncDriver) -> None: + """Test EXPLAIN PLAN FOR with WHERE clause.""" + explain_stmt = Explain("SELECT * FROM explain_test WHERE id = 1", dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_explain_from_query_builder(oracle_explain_session: OracleSyncDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin. + + Note: Uses raw SQL since query builder without dialect produces PostgreSQL-style SQL. + """ + explain_stmt = Explain("SELECT * FROM explain_test WHERE id > 0", dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_explain_from_sql_factory(oracle_explain_session: OracleSyncDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_explain_from_sql_object(oracle_explain_session: OracleSyncDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + # Use Explain directly with dialect since SQL uses default dialect + explain_stmt = Explain(stmt.sql, dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_explain_insert(oracle_explain_session: OracleSyncDriver) -> None: + """Test EXPLAIN PLAN FOR on INSERT statement.""" + explain_stmt = Explain("INSERT INTO explain_test (name, value) VALUES ('test', 1)", dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_explain_update(oracle_explain_session: OracleSyncDriver) -> None: + """Test EXPLAIN PLAN FOR on UPDATE statement.""" + explain_stmt = Explain("UPDATE explain_test SET value = 100 WHERE id = 1", dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_explain_delete(oracle_explain_session: OracleSyncDriver) -> None: + """Test EXPLAIN PLAN FOR on DELETE statement.""" + explain_stmt = Explain("DELETE FROM explain_test WHERE id = 1", dialect="oracle") + result = oracle_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + + +def test_display_execution_plan(oracle_explain_session: OracleSyncDriver) -> None: + """Test retrieving execution plan with DBMS_XPLAN.DISPLAY. + + This test demonstrates the full Oracle EXPLAIN workflow: + 1. Execute EXPLAIN PLAN FOR + 2. Query DBMS_XPLAN.DISPLAY() to get the plan + """ + explain_stmt = Explain("SELECT * FROM explain_test WHERE id = 1", dialect="oracle") + oracle_explain_session.execute(explain_stmt.build()) + + plan_result = oracle_explain_session.execute(SQL("SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY())")) + assert isinstance(plan_result, SQLResult) + assert plan_result.data is not None + assert len(plan_result.data) > 0 diff --git a/tests/integration/test_adapters/test_psqlpy/test_explain.py b/tests/integration/test_adapters/test_psqlpy/test_explain.py new file mode 100644 index 000000000..bb33892eb --- /dev/null +++ b/tests/integration/test_adapters/test_psqlpy/test_explain.py @@ -0,0 +1,110 @@ +"""Integration tests for EXPLAIN plan support with psqlpy adapter.""" + +from collections.abc import AsyncGenerator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.psqlpy import PsqlpyConfig, PsqlpyDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = [pytest.mark.xdist_group("postgres"), pytest.mark.anyio] + + +@pytest.fixture +async def psqlpy_session(psqlpy_config: PsqlpyConfig) -> AsyncGenerator[PsqlpyDriver, None]: + """Create a psqlpy session with test table.""" + async with psqlpy_config.provide_session() as session: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + await session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + value INTEGER DEFAULT 0 + ) + """ + ) + yield session + + try: + await session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +async def test_explain_basic_select(psqlpy_session: PsqlpyDriver) -> None: + """Test basic EXPLAIN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres") + result = await psqlpy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_analyze(psqlpy_session: PsqlpyDriver) -> None: + """Test EXPLAIN ANALYZE on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze() + result = await psqlpy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_with_format_json(psqlpy_session: PsqlpyDriver) -> None: + """Test EXPLAIN with JSON format.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").format("json") + result = await psqlpy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_verbose(psqlpy_session: PsqlpyDriver) -> None: + """Test EXPLAIN VERBOSE.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").verbose() + result = await psqlpy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_full_options(psqlpy_session: PsqlpyDriver) -> None: + """Test EXPLAIN with multiple options.""" + explain_stmt = ( + Explain("SELECT * FROM explain_test", dialect="postgres").analyze().verbose().buffers().timing().format("json") + ) + result = await psqlpy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_query_builder(psqlpy_session: PsqlpyDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain(analyze=True) + result = await psqlpy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_factory(psqlpy_session: PsqlpyDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", analyze=True, dialect="postgres") + result = await psqlpy_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +async def test_explain_from_sql_object(psqlpy_session: PsqlpyDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain(analyze=True) + result = await psqlpy_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_psycopg/test_explain.py b/tests/integration/test_adapters/test_psycopg/test_explain.py new file mode 100644 index 000000000..7ad489d42 --- /dev/null +++ b/tests/integration/test_adapters/test_psycopg/test_explain.py @@ -0,0 +1,181 @@ +"""Integration tests for EXPLAIN plan support with psycopg adapter.""" + +from collections.abc import Generator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.psycopg import PsycopgSyncConfig, PsycopgSyncDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL, ExplainOptions + +pytestmark = pytest.mark.xdist_group("postgres") + + +@pytest.fixture +def psycopg_session(psycopg_sync_config: PsycopgSyncConfig) -> Generator[PsycopgSyncDriver, None, None]: + """Create a psycopg session with test table.""" + with psycopg_sync_config.provide_session() as session: + session.execute_script("DROP TABLE IF EXISTS explain_test") + session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + value INTEGER DEFAULT 0 + ) + """ + ) + session.commit() + session.begin() + yield session + + try: + session.rollback() + except Exception: + pass + + try: + session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +def test_explain_basic_select(psycopg_session: PsycopgSyncDriver) -> None: + """Test basic EXPLAIN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres") + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_analyze(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN ANALYZE on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_format_json(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN with JSON format.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").format("json") + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_analyze_with_buffers(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN ANALYZE with BUFFERS option.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze().buffers() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_analyze_with_timing(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN ANALYZE with TIMING option.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze().timing() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_verbose(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN VERBOSE.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").verbose() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_full_options(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN with multiple options.""" + explain_stmt = ( + Explain("SELECT * FROM explain_test", dialect="postgres").analyze().verbose().buffers().timing().format("json") + ) + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_query_builder(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain(analyze=True) + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_factory(psycopg_session: PsycopgSyncDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", analyze=True, dialect="postgres") + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_object(psycopg_session: PsycopgSyncDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain(analyze=True) + result = psycopg_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_insert(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN on INSERT statement.""" + explain_stmt = Explain("INSERT INTO explain_test (name, value) VALUES ('test', 1)", dialect="postgres").analyze() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_update(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN on UPDATE statement.""" + explain_stmt = Explain("UPDATE explain_test SET value = 100 WHERE id = 1", dialect="postgres").analyze() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_delete(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN on DELETE statement.""" + explain_stmt = Explain("DELETE FROM explain_test WHERE id = 1", dialect="postgres").analyze() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_costs_disabled(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN with COSTS FALSE.""" + options = ExplainOptions(costs=False) + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres", options=options) + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_summary(psycopg_session: PsycopgSyncDriver) -> None: + """Test EXPLAIN ANALYZE with SUMMARY option.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="postgres").analyze().summary() + result = psycopg_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_spanner/test_explain.py b/tests/integration/test_adapters/test_spanner/test_explain.py new file mode 100644 index 000000000..4ba3f5eea --- /dev/null +++ b/tests/integration/test_adapters/test_spanner/test_explain.py @@ -0,0 +1,95 @@ +"""Integration tests for EXPLAIN plan support with spanner adapter. + +Note: Spanner uses query_mode=PLAN for execution plans, not SQL EXPLAIN syntax. +The emulator also has limited DDL support. These tests are skipped in CI. +""" + +from collections.abc import Generator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.spanner import SpannerSyncConfig, SpannerSyncDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = [ + pytest.mark.xdist_group("spanner"), + pytest.mark.skip(reason="Spanner uses query_mode=PLAN for execution plans, not SQL EXPLAIN syntax"), +] + + +@pytest.fixture +def spanner_explain_session(spanner_config: SpannerSyncConfig) -> Generator[SpannerSyncDriver, None, None]: + """Create a spanner session with test table.""" + with spanner_config.provide_session() as session: + try: + session.execute_script("DROP TABLE explain_test") + except Exception: + pass + + session.execute_script( + """ + CREATE TABLE explain_test ( + id INT64 NOT NULL, + name STRING(255) NOT NULL, + value INT64 + ) PRIMARY KEY (id) + """ + ) + yield session + + try: + session.execute_script("DROP TABLE explain_test") + except Exception: + pass + + +def test_explain_basic_select(spanner_explain_session: SpannerSyncDriver) -> None: + """Test basic EXPLAIN on SELECT statement. + + Spanner uses EXPLAIN syntax similar to generic SQL. + """ + explain_stmt = Explain("SELECT * FROM explain_test", dialect="spanner") + result = spanner_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_with_where(spanner_explain_session: SpannerSyncDriver) -> None: + """Test EXPLAIN with WHERE clause.""" + explain_stmt = Explain("SELECT * FROM explain_test WHERE id = 1", dialect="spanner") + result = spanner_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_query_builder(spanner_explain_session: SpannerSyncDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain() + result = spanner_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_factory(spanner_explain_session: SpannerSyncDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", dialect="spanner") + result = spanner_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_object(spanner_explain_session: SpannerSyncDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain() + result = spanner_explain_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/integration/test_adapters/test_sqlite/test_explain.py b/tests/integration/test_adapters/test_sqlite/test_explain.py new file mode 100644 index 000000000..7f8734a1c --- /dev/null +++ b/tests/integration/test_adapters/test_sqlite/test_explain.py @@ -0,0 +1,147 @@ +"""Integration tests for EXPLAIN plan support with sqlite adapter.""" + +from collections.abc import Generator + +import pytest + +from sqlspec import SQLResult +from sqlspec.adapters.sqlite import SqliteConfig, SqliteDriver +from sqlspec.builder import Explain, sql +from sqlspec.core import SQL + +pytestmark = pytest.mark.xdist_group("sqlite") + + +@pytest.fixture +def sqlite_explain_session() -> Generator[SqliteDriver, None, None]: + """Create a sqlite session with test table.""" + config = SqliteConfig(connection_config={"database": ":memory:"}) + with config.provide_session() as session: + session.execute_script("DROP TABLE IF EXISTS explain_test") + session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + value INTEGER DEFAULT 0 + ) + """ + ) + session.commit() + yield session + + try: + session.execute_script("DROP TABLE IF EXISTS explain_test") + except Exception: + pass + + +def test_explain_query_plan_select(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on SELECT statement.""" + explain_stmt = Explain("SELECT * FROM explain_test", dialect="sqlite") + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_query_plan_with_where(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN with WHERE clause.""" + explain_stmt = Explain("SELECT * FROM explain_test WHERE id = 1", dialect="sqlite") + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_query_plan_with_join(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN with JOIN.""" + sqlite_explain_session.execute_script("DROP TABLE IF EXISTS explain_test2") + sqlite_explain_session.execute_script( + """ + CREATE TABLE IF NOT EXISTS explain_test2 ( + id INTEGER PRIMARY KEY, + test_id INTEGER, + data TEXT + ) + """ + ) + sqlite_explain_session.commit() + + try: + explain_stmt = Explain( + "SELECT * FROM explain_test e JOIN explain_test2 e2 ON e.id = e2.test_id", dialect="sqlite" + ) + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + finally: + sqlite_explain_session.execute_script("DROP TABLE IF EXISTS explain_test2") + + +def test_explain_from_query_builder(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN from QueryBuilder via mixin.""" + query = sql.select("*").from_("explain_test").where("id > :id", id=0) + explain_stmt = query.explain() + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_factory(sqlite_explain_session: SqliteDriver) -> None: + """Test sql.explain() factory method.""" + explain_stmt = sql.explain("SELECT * FROM explain_test", dialect="sqlite") + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_from_sql_object(sqlite_explain_session: SqliteDriver) -> None: + """Test SQL.explain() method.""" + stmt = SQL("SELECT * FROM explain_test") + explain_stmt = stmt.explain() + result = sqlite_explain_session.execute(explain_stmt) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_insert(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on INSERT statement.""" + explain_stmt = Explain("INSERT INTO explain_test (name, value) VALUES ('test', 1)", dialect="sqlite") + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_update(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on UPDATE statement.""" + explain_stmt = Explain("UPDATE explain_test SET value = 100 WHERE id = 1", dialect="sqlite") + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_delete(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN on DELETE statement.""" + explain_stmt = Explain("DELETE FROM explain_test WHERE id = 1", dialect="sqlite") + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None + + +def test_explain_subquery(sqlite_explain_session: SqliteDriver) -> None: + """Test EXPLAIN QUERY PLAN with subquery.""" + explain_stmt = Explain( + "SELECT * FROM explain_test WHERE id IN (SELECT id FROM explain_test WHERE value > 0)", dialect="sqlite" + ) + result = sqlite_explain_session.execute(explain_stmt.build()) + + assert isinstance(result, SQLResult) + assert result.data is not None diff --git a/tests/unit/test_explain.py b/tests/unit/test_explain.py new file mode 100644 index 000000000..dd115a32c --- /dev/null +++ b/tests/unit/test_explain.py @@ -0,0 +1,1522 @@ +"""Unit tests for EXPLAIN plan support. + +This module tests the EXPLAIN statement building functionality including: +1. ExplainOptions - Configuration options for EXPLAIN statements +2. ExplainFormat - Output format enumeration +3. Explain builder - Fluent interface for building EXPLAIN statements +4. Dialect-specific SQL generation +5. Integration with QueryBuilders (Select, Insert, Update, Delete, Merge) +6. SQLFactory integration +7. SQL class integration + +Test Coverage: +- ExplainOptions creation and methods (__init__, copy, to_dict, __eq__, __hash__, __repr__) +- ExplainFormat enumeration values +- Explain builder construction and method chaining +- Dialect-specific EXPLAIN SQL generation (PostgreSQL, MySQL, SQLite, DuckDB, Oracle, BigQuery) +- Parameter preservation from underlying statements +- Integration with all query builders that use ExplainMixin +""" + +# pyright: reportPrivateUsage=false +# mypy: disable-error-code="comparison-overlap,arg-type" + +import pytest +from sqlglot import exp + +from sqlspec.builder import Delete, Insert, Merge, Select, Update +from sqlspec.builder._explain import ( + Explain, + _build_bigquery_explain, + _build_duckdb_explain, + _build_generic_explain, + _build_mysql_explain, + _build_oracle_explain, + _build_postgres_explain, + _build_sqlite_explain, + _normalize_dialect_name, + build_explain_sql, +) +from sqlspec.builder._factory import SQLFactory, sql +from sqlspec.core import SQL +from sqlspec.core.explain import ExplainFormat, ExplainOptions + +pytestmark = pytest.mark.xdist_group("explain") + + +# ----------------------------------------------------------------------------- +# ExplainFormat Tests +# ----------------------------------------------------------------------------- + + +def test_explain_format_text_value(): + """Test ExplainFormat.TEXT has correct value.""" + assert ExplainFormat.TEXT.value == "text" + + +def test_explain_format_json_value(): + """Test ExplainFormat.JSON has correct value.""" + assert ExplainFormat.JSON.value == "json" + + +def test_explain_format_xml_value(): + """Test ExplainFormat.XML has correct value.""" + assert ExplainFormat.XML.value == "xml" + + +def test_explain_format_yaml_value(): + """Test ExplainFormat.YAML has correct value.""" + assert ExplainFormat.YAML.value == "yaml" + + +def test_explain_format_tree_value(): + """Test ExplainFormat.TREE has correct value.""" + assert ExplainFormat.TREE.value == "tree" + + +def test_explain_format_traditional_value(): + """Test ExplainFormat.TRADITIONAL has correct value.""" + assert ExplainFormat.TRADITIONAL.value == "traditional" + + +def test_explain_format_is_string_enum(): + """Test ExplainFormat inherits from str.""" + assert isinstance(ExplainFormat.TEXT, str) + assert ExplainFormat.JSON == "json" + + +def test_explain_format_creation_from_string(): + """Test ExplainFormat can be created from lowercase string.""" + assert ExplainFormat("json") == ExplainFormat.JSON + assert ExplainFormat("xml") == ExplainFormat.XML + + +# ----------------------------------------------------------------------------- +# ExplainOptions Tests +# ----------------------------------------------------------------------------- + + +def test_explain_options_default_values(): + """Test ExplainOptions has correct default values.""" + options = ExplainOptions() + + assert options.analyze is False + assert options.verbose is False + assert options.format is None + assert options.costs is None + assert options.buffers is None + assert options.timing is None + assert options.summary is None + assert options.memory is None + assert options.settings is None + assert options.wal is None + assert options.generic_plan is None + + +def test_explain_options_with_analyze(): + """Test ExplainOptions with analyze=True.""" + options = ExplainOptions(analyze=True) + + assert options.analyze is True + assert options.verbose is False + + +def test_explain_options_with_verbose(): + """Test ExplainOptions with verbose=True.""" + options = ExplainOptions(verbose=True) + + assert options.verbose is True + assert options.analyze is False + + +def test_explain_options_with_format_enum(): + """Test ExplainOptions with format as ExplainFormat enum.""" + options = ExplainOptions(format=ExplainFormat.JSON) + + assert options.format == ExplainFormat.JSON + + +def test_explain_options_with_format_string(): + """Test ExplainOptions with format as string (converted to enum).""" + options = ExplainOptions(format="json") + + assert options.format == ExplainFormat.JSON + + +def test_explain_options_with_format_uppercase_string(): + """Test ExplainOptions with uppercase format string (converted to lowercase).""" + options = ExplainOptions(format="JSON") + + assert options.format == ExplainFormat.JSON + + +def test_explain_options_with_all_boolean_options(): + """Test ExplainOptions with all boolean options set.""" + options = ExplainOptions( + analyze=True, + verbose=True, + costs=True, + buffers=True, + timing=True, + summary=True, + memory=True, + settings=True, + wal=True, + generic_plan=True, + ) + + assert options.analyze is True + assert options.verbose is True + assert options.costs is True + assert options.buffers is True + assert options.timing is True + assert options.summary is True + assert options.memory is True + assert options.settings is True + assert options.wal is True + assert options.generic_plan is True + + +def test_explain_options_with_false_boolean_options(): + """Test ExplainOptions with explicitly False boolean options.""" + options = ExplainOptions(costs=False, buffers=False) + + assert options.costs is False + assert options.buffers is False + + +def test_explain_options_copy_no_modifications(): + """Test ExplainOptions.copy() with no modifications returns equivalent object.""" + original = ExplainOptions(analyze=True, format=ExplainFormat.JSON) + copied = original.copy() + + assert copied == original + assert copied is not original + + +def test_explain_options_copy_with_analyze_override(): + """Test ExplainOptions.copy() with analyze override.""" + original = ExplainOptions(analyze=False) + copied = original.copy(analyze=True) + + assert copied.analyze is True + assert original.analyze is False + + +def test_explain_options_copy_with_format_override(): + """Test ExplainOptions.copy() with format override.""" + original = ExplainOptions(format=ExplainFormat.TEXT) + copied = original.copy(format=ExplainFormat.JSON) + + assert copied.format == ExplainFormat.JSON + assert original.format == ExplainFormat.TEXT + + +def test_explain_options_copy_with_multiple_overrides(): + """Test ExplainOptions.copy() with multiple overrides.""" + original = ExplainOptions(analyze=False, verbose=False, costs=None) + copied = original.copy(analyze=True, verbose=True, buffers=True) + + assert copied.analyze is True + assert copied.verbose is True + assert copied.buffers is True + assert copied.costs is None + + +def test_explain_options_to_dict_empty(): + """Test ExplainOptions.to_dict() with default options returns empty dict.""" + options = ExplainOptions() + result = options.to_dict() + + assert result == {} + + +def test_explain_options_to_dict_with_analyze(): + """Test ExplainOptions.to_dict() includes analyze when True.""" + options = ExplainOptions(analyze=True) + result = options.to_dict() + + assert result == {"analyze": True} + + +def test_explain_options_to_dict_with_verbose(): + """Test ExplainOptions.to_dict() includes verbose when True.""" + options = ExplainOptions(verbose=True) + result = options.to_dict() + + assert result == {"verbose": True} + + +def test_explain_options_to_dict_with_format(): + """Test ExplainOptions.to_dict() includes format value as uppercase string.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = options.to_dict() + + assert result == {"format": "JSON"} + + +def test_explain_options_to_dict_with_boolean_options(): + """Test ExplainOptions.to_dict() includes boolean options when set.""" + options = ExplainOptions(costs=True, buffers=False, timing=True) + result = options.to_dict() + + assert result == {"costs": True, "buffers": False, "timing": True} + + +def test_explain_options_to_dict_full(): + """Test ExplainOptions.to_dict() with all options set.""" + options = ExplainOptions( + analyze=True, + verbose=True, + format=ExplainFormat.JSON, + costs=True, + buffers=True, + timing=True, + summary=True, + memory=True, + settings=True, + wal=True, + generic_plan=True, + ) + result = options.to_dict() + + assert result == { + "analyze": True, + "verbose": True, + "format": "JSON", + "costs": True, + "buffers": True, + "timing": True, + "summary": True, + "memory": True, + "settings": True, + "wal": True, + "generic_plan": True, + } + + +def test_explain_options_equality_same_values(): + """Test ExplainOptions equality with same values.""" + options1 = ExplainOptions(analyze=True, format=ExplainFormat.JSON) + options2 = ExplainOptions(analyze=True, format=ExplainFormat.JSON) + + assert options1 == options2 + + +def test_explain_options_equality_different_values(): + """Test ExplainOptions inequality with different values.""" + options1 = ExplainOptions(analyze=True) + options2 = ExplainOptions(analyze=False) + + assert options1 != options2 + + +def test_explain_options_equality_non_explain_options(): + """Test ExplainOptions inequality with non-ExplainOptions object.""" + options = ExplainOptions() + + assert options != "not an ExplainOptions" + assert options != 123 + assert options is not None + assert (options == None) is False # noqa: E711 + + +def test_explain_options_hash_same_values(): + """Test ExplainOptions hash is same for equal objects.""" + options1 = ExplainOptions(analyze=True, format=ExplainFormat.JSON) + options2 = ExplainOptions(analyze=True, format=ExplainFormat.JSON) + + assert hash(options1) == hash(options2) + + +def test_explain_options_hash_different_values(): + """Test ExplainOptions hash differs for different objects.""" + options1 = ExplainOptions(analyze=True) + options2 = ExplainOptions(analyze=False) + + assert hash(options1) != hash(options2) + + +def test_explain_options_hash_usable_in_set(): + """Test ExplainOptions can be used in sets.""" + options1 = ExplainOptions(analyze=True) + options2 = ExplainOptions(analyze=True) + options3 = ExplainOptions(analyze=False) + + option_set = {options1, options2, options3} + + assert len(option_set) == 2 + + +def test_explain_options_repr_empty(): + """Test ExplainOptions.__repr__ with default options.""" + options = ExplainOptions() + repr_str = repr(options) + + assert repr_str == "ExplainOptions()" + + +def test_explain_options_repr_with_analyze(): + """Test ExplainOptions.__repr__ with analyze=True.""" + options = ExplainOptions(analyze=True) + repr_str = repr(options) + + assert "analyze=True" in repr_str + + +def test_explain_options_repr_with_format(): + """Test ExplainOptions.__repr__ with format.""" + options = ExplainOptions(format=ExplainFormat.JSON) + repr_str = repr(options) + + assert "format='json'" in repr_str + + +def test_explain_options_repr_with_multiple_options(): + """Test ExplainOptions.__repr__ with multiple options.""" + options = ExplainOptions(analyze=True, verbose=True, costs=True) + repr_str = repr(options) + + assert "analyze=True" in repr_str + assert "verbose=True" in repr_str + assert "costs=True" in repr_str + + +def test_explain_options_has_slots(): + """Test ExplainOptions uses __slots__ for memory efficiency.""" + options = ExplainOptions() + + assert not hasattr(options, "__dict__") + + +# ----------------------------------------------------------------------------- +# Explain Builder Tests - Basic Construction +# ----------------------------------------------------------------------------- + + +def test_explain_basic_construction_from_string(): + """Test Explain construction from SQL string.""" + explain = Explain("SELECT * FROM users") + + assert explain._statement_sql == "SELECT * FROM users" + assert explain._dialect is None + assert explain._options == ExplainOptions() + + +def test_explain_construction_with_dialect(): + """Test Explain construction with dialect.""" + explain = Explain("SELECT * FROM users", dialect="postgres") + + assert explain._dialect == "postgres" + + +def test_explain_construction_with_options(): + """Test Explain construction with ExplainOptions.""" + options = ExplainOptions(analyze=True, verbose=True) + explain = Explain("SELECT * FROM users", options=options) + + assert explain._options == options + + +def test_explain_construction_from_sql_object(): + """Test Explain construction from SQL object.""" + sql_obj = SQL("SELECT * FROM users WHERE id = :id", {"id": 1}) + explain = Explain(sql_obj) + + assert "SELECT * FROM users" in explain._statement_sql + assert explain._parameters == {"id": 1} + + +def test_explain_construction_from_sqlglot_expression(): + """Test Explain construction from sqlglot expression.""" + expression = exp.Select().select("*").from_("users") + explain = Explain(expression, dialect="postgres") + + assert "USERS" in explain._statement_sql.upper() + assert "SELECT" in explain._statement_sql.upper() + + +def test_explain_construction_from_query_builder(): + """Test Explain construction from QueryBuilder.""" + builder = Select("*").from_("users").where_eq("id", 1) + explain = Explain(builder) # pyright: ignore[reportArgumentType] + + assert "USERS" in explain._statement_sql.upper() + assert "SELECT" in explain._statement_sql.upper() + + +# ----------------------------------------------------------------------------- +# Explain Builder Tests - Method Chaining +# ----------------------------------------------------------------------------- + + +def test_explain_analyze_method_chaining(): + """Test Explain.analyze() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.analyze() + + assert result is explain + assert explain._options.analyze is True + + +def test_explain_analyze_disabled(): + """Test Explain.analyze(False) disables analyze.""" + explain = Explain("SELECT * FROM users").analyze(True).analyze(False) + + assert explain._options.analyze is False + + +def test_explain_verbose_method_chaining(): + """Test Explain.verbose() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.verbose() + + assert result is explain + assert explain._options.verbose is True + + +def test_explain_format_method_chaining_with_enum(): + """Test Explain.format() with ExplainFormat enum.""" + explain = Explain("SELECT * FROM users").format(ExplainFormat.JSON) + + assert explain._options.format == ExplainFormat.JSON + + +def test_explain_format_method_chaining_with_string(): + """Test Explain.format() with string converts to enum.""" + explain = Explain("SELECT * FROM users").format("json") + + assert explain._options.format == ExplainFormat.JSON + + +def test_explain_costs_method_chaining(): + """Test Explain.costs() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.costs() + + assert result is explain + assert explain._options.costs is True + + +def test_explain_buffers_method_chaining(): + """Test Explain.buffers() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.buffers() + + assert result is explain + assert explain._options.buffers is True + + +def test_explain_timing_method_chaining(): + """Test Explain.timing() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.timing() + + assert result is explain + assert explain._options.timing is True + + +def test_explain_summary_method_chaining(): + """Test Explain.summary() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.summary() + + assert result is explain + assert explain._options.summary is True + + +def test_explain_memory_method_chaining(): + """Test Explain.memory() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.memory() + + assert result is explain + assert explain._options.memory is True + + +def test_explain_settings_method_chaining(): + """Test Explain.settings() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.settings() + + assert result is explain + assert explain._options.settings is True + + +def test_explain_wal_method_chaining(): + """Test Explain.wal() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.wal() + + assert result is explain + assert explain._options.wal is True + + +def test_explain_generic_plan_method_chaining(): + """Test Explain.generic_plan() returns self for chaining.""" + explain = Explain("SELECT * FROM users") + result = explain.generic_plan() + + assert result is explain + assert explain._options.generic_plan is True + + +def test_explain_with_options_method(): + """Test Explain.with_options() replaces all options.""" + new_options = ExplainOptions(analyze=True, format=ExplainFormat.JSON) + explain = Explain("SELECT * FROM users").with_options(new_options) + + assert explain._options is new_options + + +def test_explain_full_chaining(): + """Test full method chaining on Explain builder.""" + explain = ( + Explain("SELECT * FROM users", dialect="postgres") + .analyze() + .verbose() + .format(ExplainFormat.JSON) + .costs() + .buffers() + .timing() + ) + + assert explain._options.analyze is True + assert explain._options.verbose is True + assert explain._options.format == ExplainFormat.JSON + assert explain._options.costs is True + assert explain._options.buffers is True + assert explain._options.timing is True + + +# ----------------------------------------------------------------------------- +# Explain Builder Tests - Properties +# ----------------------------------------------------------------------------- + + +def test_explain_options_property(): + """Test Explain.options property returns current options.""" + options = ExplainOptions(analyze=True) + explain = Explain("SELECT * FROM users", options=options) + + assert explain.options == options + + +def test_explain_dialect_property(): + """Test Explain.dialect property returns current dialect.""" + explain = Explain("SELECT * FROM users", dialect="postgres") + + assert explain.dialect == "postgres" + + +def test_explain_parameters_property_empty(): + """Test Explain.parameters returns empty dict when no parameters.""" + explain = Explain("SELECT * FROM users") + + assert explain.parameters == {} + + +def test_explain_parameters_property_from_sql_object(): + """Test Explain.parameters returns parameters from SQL object.""" + sql_obj = SQL("SELECT * FROM users WHERE id = :id", {"id": 1}) + explain = Explain(sql_obj) + + assert explain.parameters == {"id": 1} + + +def test_explain_parameters_property_is_copy(): + """Test Explain.parameters returns a copy.""" + sql_obj = SQL("SELECT * FROM users WHERE id = :id", {"id": 1}) + explain = Explain(sql_obj) + + params = explain.parameters + params["new_key"] = "new_value" + + assert "new_key" not in explain._parameters + + +# ----------------------------------------------------------------------------- +# Explain Builder Tests - Build Methods +# ----------------------------------------------------------------------------- + + +def test_explain_build_returns_sql_object(): + """Test Explain.build() returns SQL object.""" + explain = Explain("SELECT * FROM users") + result = explain.build() + + assert isinstance(result, SQL) + + +def test_explain_build_with_dialect_override(): + """Test Explain.build() respects dialect override.""" + explain = Explain("SELECT * FROM users", dialect="postgres") + result = explain.build(dialect="mysql") + + assert "EXPLAIN" in result.raw_sql.upper() + + +def test_explain_build_preserves_parameters(): + """Test Explain.build() preserves parameters from SQL object.""" + sql_obj = SQL("SELECT * FROM users WHERE id = :id", {"id": 1}) + explain = Explain(sql_obj) + result = explain.build() + + assert result.named_parameters == {"id": 1} + + +def test_explain_to_sql_returns_string(): + """Test Explain.to_sql() returns SQL string.""" + explain = Explain("SELECT * FROM users") + result = explain.to_sql() + + assert isinstance(result, str) + assert "EXPLAIN" in result.upper() + + +def test_explain_to_sql_with_dialect_override(): + """Test Explain.to_sql() respects dialect override.""" + explain = Explain("SELECT * FROM users", dialect="postgres") + result_postgres = explain.to_sql() + result_mysql = explain.to_sql(dialect="mysql") + + assert "EXPLAIN" in result_postgres.upper() + assert "EXPLAIN" in result_mysql.upper() + + +def test_explain_repr(): + """Test Explain.__repr__() returns useful string.""" + explain = Explain("SELECT * FROM users", dialect="postgres") + repr_str = repr(explain) + + assert "Explain" in repr_str + assert "SELECT * FROM users" in repr_str + assert "postgres" in repr_str + + +# ----------------------------------------------------------------------------- +# Dialect-Specific SQL Generation Tests - PostgreSQL +# ----------------------------------------------------------------------------- + + +def test_build_postgres_explain_basic(): + """Test PostgreSQL basic EXPLAIN generation.""" + options = ExplainOptions() + result = _build_postgres_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN SELECT * FROM users" + + +def test_build_postgres_explain_analyze(): + """Test PostgreSQL EXPLAIN ANALYZE generation.""" + options = ExplainOptions(analyze=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "EXPLAIN (ANALYZE)" in result + assert "SELECT * FROM users" in result + + +def test_build_postgres_explain_verbose(): + """Test PostgreSQL EXPLAIN VERBOSE generation.""" + options = ExplainOptions(verbose=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "EXPLAIN (VERBOSE)" in result + + +def test_build_postgres_explain_format_json(): + """Test PostgreSQL EXPLAIN FORMAT JSON generation.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "EXPLAIN (FORMAT JSON)" in result + + +def test_build_postgres_explain_format_xml(): + """Test PostgreSQL EXPLAIN FORMAT XML generation.""" + options = ExplainOptions(format=ExplainFormat.XML) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "FORMAT XML" in result + + +def test_build_postgres_explain_format_yaml(): + """Test PostgreSQL EXPLAIN FORMAT YAML generation.""" + options = ExplainOptions(format=ExplainFormat.YAML) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "FORMAT YAML" in result + + +def test_build_postgres_explain_costs_true(): + """Test PostgreSQL EXPLAIN COSTS TRUE generation.""" + options = ExplainOptions(costs=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "COSTS TRUE" in result + + +def test_build_postgres_explain_costs_false(): + """Test PostgreSQL EXPLAIN COSTS FALSE generation.""" + options = ExplainOptions(costs=False) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "COSTS FALSE" in result + + +def test_build_postgres_explain_buffers(): + """Test PostgreSQL EXPLAIN BUFFERS generation.""" + options = ExplainOptions(buffers=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "BUFFERS TRUE" in result + + +def test_build_postgres_explain_timing(): + """Test PostgreSQL EXPLAIN TIMING generation.""" + options = ExplainOptions(timing=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "TIMING TRUE" in result + + +def test_build_postgres_explain_summary(): + """Test PostgreSQL EXPLAIN SUMMARY generation.""" + options = ExplainOptions(summary=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "SUMMARY TRUE" in result + + +def test_build_postgres_explain_memory(): + """Test PostgreSQL EXPLAIN MEMORY generation.""" + options = ExplainOptions(memory=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "MEMORY TRUE" in result + + +def test_build_postgres_explain_settings(): + """Test PostgreSQL EXPLAIN SETTINGS generation.""" + options = ExplainOptions(settings=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "SETTINGS TRUE" in result + + +def test_build_postgres_explain_wal(): + """Test PostgreSQL EXPLAIN WAL generation.""" + options = ExplainOptions(wal=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "WAL TRUE" in result + + +def test_build_postgres_explain_generic_plan(): + """Test PostgreSQL EXPLAIN GENERIC_PLAN generation.""" + options = ExplainOptions(generic_plan=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "GENERIC_PLAN TRUE" in result + + +def test_build_postgres_explain_full_options(): + """Test PostgreSQL EXPLAIN with multiple options.""" + options = ExplainOptions(analyze=True, verbose=True, format=ExplainFormat.JSON, costs=True, buffers=True) + result = _build_postgres_explain("SELECT * FROM users", options) + + assert "EXPLAIN (" in result + assert "ANALYZE" in result + assert "VERBOSE" in result + assert "FORMAT JSON" in result + assert "COSTS TRUE" in result + assert "BUFFERS TRUE" in result + + +# ----------------------------------------------------------------------------- +# Dialect-Specific SQL Generation Tests - MySQL +# ----------------------------------------------------------------------------- + + +def test_build_mysql_explain_basic(): + """Test MySQL basic EXPLAIN generation.""" + options = ExplainOptions() + result = _build_mysql_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN SELECT * FROM users" + + +def test_build_mysql_explain_analyze(): + """Test MySQL EXPLAIN ANALYZE generation.""" + options = ExplainOptions(analyze=True) + result = _build_mysql_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN ANALYZE SELECT * FROM users" + + +def test_build_mysql_explain_format_json(): + """Test MySQL EXPLAIN FORMAT JSON generation.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = _build_mysql_explain("SELECT * FROM users", options) + + assert "EXPLAIN FORMAT = JSON" in result + + +def test_build_mysql_explain_format_tree(): + """Test MySQL EXPLAIN FORMAT TREE generation.""" + options = ExplainOptions(format=ExplainFormat.TREE) + result = _build_mysql_explain("SELECT * FROM users", options) + + assert "EXPLAIN FORMAT = TREE" in result + + +def test_build_mysql_explain_format_traditional(): + """Test MySQL EXPLAIN FORMAT TRADITIONAL generation.""" + options = ExplainOptions(format=ExplainFormat.TRADITIONAL) + result = _build_mysql_explain("SELECT * FROM users", options) + + assert "EXPLAIN FORMAT = TRADITIONAL" in result + + +def test_build_mysql_explain_format_text_maps_to_traditional(): + """Test MySQL maps TEXT format to TRADITIONAL.""" + options = ExplainOptions(format=ExplainFormat.TEXT) + result = _build_mysql_explain("SELECT * FROM users", options) + + assert "EXPLAIN FORMAT = TRADITIONAL" in result + + +def test_build_mysql_explain_analyze_ignores_format(): + """Test MySQL EXPLAIN ANALYZE ignores format (always uses TREE).""" + options = ExplainOptions(analyze=True, format=ExplainFormat.JSON) + result = _build_mysql_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN ANALYZE SELECT * FROM users" + + +# ----------------------------------------------------------------------------- +# Dialect-Specific SQL Generation Tests - SQLite +# ----------------------------------------------------------------------------- + + +def test_build_sqlite_explain_basic(): + """Test SQLite basic EXPLAIN QUERY PLAN generation.""" + options = ExplainOptions() + result = _build_sqlite_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN QUERY PLAN SELECT * FROM users" + + +def test_build_sqlite_explain_ignores_analyze(): + """Test SQLite EXPLAIN ignores analyze option.""" + options = ExplainOptions(analyze=True) + result = _build_sqlite_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN QUERY PLAN SELECT * FROM users" + + +def test_build_sqlite_explain_ignores_format(): + """Test SQLite EXPLAIN ignores format option.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = _build_sqlite_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN QUERY PLAN SELECT * FROM users" + + +# ----------------------------------------------------------------------------- +# Dialect-Specific SQL Generation Tests - DuckDB +# ----------------------------------------------------------------------------- + + +def test_build_duckdb_explain_basic(): + """Test DuckDB basic EXPLAIN generation.""" + options = ExplainOptions() + result = _build_duckdb_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN SELECT * FROM users" + + +def test_build_duckdb_explain_analyze(): + """Test DuckDB EXPLAIN ANALYZE generation.""" + options = ExplainOptions(analyze=True) + result = _build_duckdb_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN ANALYZE SELECT * FROM users" + + +def test_build_duckdb_explain_format_json(): + """Test DuckDB EXPLAIN FORMAT JSON generation.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = _build_duckdb_explain("SELECT * FROM users", options) + + assert "EXPLAIN (FORMAT JSON)" in result + + +def test_build_duckdb_explain_format_text_no_parentheses(): + """Test DuckDB EXPLAIN with TEXT format uses plain EXPLAIN.""" + options = ExplainOptions(format=ExplainFormat.TEXT) + result = _build_duckdb_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN SELECT * FROM users" + + +# ----------------------------------------------------------------------------- +# Dialect-Specific SQL Generation Tests - Oracle +# ----------------------------------------------------------------------------- + + +def test_build_oracle_explain_basic(): + """Test Oracle EXPLAIN PLAN FOR generation.""" + options = ExplainOptions() + result = _build_oracle_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN PLAN FOR SELECT * FROM users" + + +def test_build_oracle_explain_ignores_analyze(): + """Test Oracle EXPLAIN PLAN ignores analyze option.""" + options = ExplainOptions(analyze=True) + result = _build_oracle_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN PLAN FOR SELECT * FROM users" + + +def test_build_oracle_explain_ignores_format(): + """Test Oracle EXPLAIN PLAN ignores format option.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = _build_oracle_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN PLAN FOR SELECT * FROM users" + + +# ----------------------------------------------------------------------------- +# Dialect-Specific SQL Generation Tests - BigQuery +# ----------------------------------------------------------------------------- + + +def test_build_bigquery_explain_basic(): + """Test BigQuery basic EXPLAIN generation.""" + options = ExplainOptions() + result = _build_bigquery_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN SELECT * FROM users" + + +def test_build_bigquery_explain_analyze(): + """Test BigQuery EXPLAIN ANALYZE generation.""" + options = ExplainOptions(analyze=True) + result = _build_bigquery_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN ANALYZE SELECT * FROM users" + + +# ----------------------------------------------------------------------------- +# Dialect-Specific SQL Generation Tests - Generic +# ----------------------------------------------------------------------------- + + +def test_build_generic_explain_basic(): + """Test generic EXPLAIN generation.""" + options = ExplainOptions() + result = _build_generic_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN SELECT * FROM users" + + +def test_build_generic_explain_analyze(): + """Test generic EXPLAIN ANALYZE generation.""" + options = ExplainOptions(analyze=True) + result = _build_generic_explain("SELECT * FROM users", options) + + assert result == "EXPLAIN ANALYZE SELECT * FROM users" + + +# ----------------------------------------------------------------------------- +# build_explain_sql Tests +# ----------------------------------------------------------------------------- + + +def test_build_explain_sql_postgres(): + """Test build_explain_sql dispatches to PostgreSQL builder.""" + options = ExplainOptions(analyze=True) + result = build_explain_sql("SELECT 1", options, dialect="postgres") + + assert "EXPLAIN (ANALYZE)" in result + + +def test_build_explain_sql_postgresql(): + """Test build_explain_sql handles 'postgresql' dialect.""" + options = ExplainOptions(analyze=True) + result = build_explain_sql("SELECT 1", options, dialect="postgresql") + + assert "EXPLAIN (ANALYZE)" in result + + +def test_build_explain_sql_mysql(): + """Test build_explain_sql dispatches to MySQL builder.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = build_explain_sql("SELECT 1", options, dialect="mysql") + + assert "FORMAT = JSON" in result + + +def test_build_explain_sql_mariadb(): + """Test build_explain_sql handles 'mariadb' dialect.""" + options = ExplainOptions(analyze=True) + result = build_explain_sql("SELECT 1", options, dialect="mariadb") + + assert "EXPLAIN ANALYZE" in result + + +def test_build_explain_sql_sqlite(): + """Test build_explain_sql dispatches to SQLite builder.""" + options = ExplainOptions() + result = build_explain_sql("SELECT 1", options, dialect="sqlite") + + assert "EXPLAIN QUERY PLAN" in result + + +def test_build_explain_sql_duckdb(): + """Test build_explain_sql dispatches to DuckDB builder.""" + options = ExplainOptions(format=ExplainFormat.JSON) + result = build_explain_sql("SELECT 1", options, dialect="duckdb") + + assert "FORMAT JSON" in result + + +def test_build_explain_sql_oracle(): + """Test build_explain_sql dispatches to Oracle builder.""" + options = ExplainOptions() + result = build_explain_sql("SELECT 1", options, dialect="oracle") + + assert "EXPLAIN PLAN FOR" in result + + +def test_build_explain_sql_bigquery(): + """Test build_explain_sql dispatches to BigQuery builder.""" + options = ExplainOptions(analyze=True) + result = build_explain_sql("SELECT 1", options, dialect="bigquery") + + assert "EXPLAIN ANALYZE" in result + + +def test_build_explain_sql_unknown_dialect(): + """Test build_explain_sql uses generic builder for unknown dialect.""" + options = ExplainOptions(analyze=True) + result = build_explain_sql("SELECT 1", options, dialect="unknown_db") + + assert "EXPLAIN ANALYZE" in result + + +def test_build_explain_sql_none_dialect(): + """Test build_explain_sql uses generic builder when dialect is None.""" + options = ExplainOptions() + result = build_explain_sql("SELECT 1", options, dialect=None) + + assert result == "EXPLAIN SELECT 1" + + +# ----------------------------------------------------------------------------- +# _normalize_dialect_name Tests +# ----------------------------------------------------------------------------- + + +def test_normalize_dialect_name_none(): + """Test _normalize_dialect_name with None.""" + assert _normalize_dialect_name(None) is None + + +def test_normalize_dialect_name_lowercase_string(): + """Test _normalize_dialect_name with lowercase string.""" + assert _normalize_dialect_name("postgres") == "postgres" + + +def test_normalize_dialect_name_uppercase_string(): + """Test _normalize_dialect_name converts to lowercase.""" + assert _normalize_dialect_name("POSTGRES") == "postgres" + + +def test_normalize_dialect_name_mixed_case_string(): + """Test _normalize_dialect_name converts mixed case to lowercase.""" + assert _normalize_dialect_name("PostgreSQL") == "postgresql" + + +# ----------------------------------------------------------------------------- +# QueryBuilder Integration Tests - ExplainMixin +# ----------------------------------------------------------------------------- + + +def test_select_has_explain_method(): + """Test Select has explain() method from ExplainMixin.""" + query = Select("*").from_("users") + + assert hasattr(query, "explain") + assert callable(query.explain) + + +def test_insert_has_explain_method(): + """Test Insert has explain() method from ExplainMixin.""" + query = Insert().into("users") + + assert hasattr(query, "explain") + assert callable(query.explain) + + +def test_update_has_explain_method(): + """Test Update has explain() method from ExplainMixin.""" + query = Update("users") + + assert hasattr(query, "explain") + assert callable(query.explain) + + +def test_delete_has_explain_method(): + """Test Delete has explain() method from ExplainMixin.""" + query = Delete("users") + + assert hasattr(query, "explain") + assert callable(query.explain) + + +def test_merge_has_explain_method(): + """Test Merge has explain() method from ExplainMixin.""" + query = Merge("products") + + assert hasattr(query, "explain") + assert callable(query.explain) + + +def test_select_explain_returns_explain_builder(): + """Test Select.explain() returns Explain builder.""" + query = Select("*").from_("users") + explain = query.explain() + + assert isinstance(explain, Explain) + + +def test_select_explain_with_options(): + """Test Select.explain() accepts options.""" + query = Select("*").from_("users") + explain = query.explain(analyze=True, verbose=True, format="json") + + assert explain._options.analyze is True + assert explain._options.verbose is True + assert explain._options.format == ExplainFormat.JSON + + +def test_select_explain_preserves_parameters(): + """Test Select.explain() preserves query parameters.""" + query = Select("*").from_("users").where_eq("id", 1) + explain = query.explain() + + assert explain._parameters.get("id") == 1 + + +def test_select_explain_build_generates_sql(): + """Test Select.explain().build() generates EXPLAIN SQL.""" + query = Select("*", dialect="postgres").from_("users") + explain_sql = query.explain().build() + + assert "EXPLAIN" in explain_sql.raw_sql.upper() + assert "SELECT" in explain_sql.raw_sql.upper() + + +def test_insert_explain_build_generates_sql(): + """Test Insert.explain().build() generates EXPLAIN SQL.""" + query = Insert(dialect="postgres").into("users").columns("name", "email").values("John", "john@example.com") + explain_sql = query.explain().build() + + assert "EXPLAIN" in explain_sql.raw_sql.upper() + assert "INSERT" in explain_sql.raw_sql.upper() + + +def test_update_explain_build_generates_sql(): + """Test Update.explain().build() generates EXPLAIN SQL.""" + query = Update("users", dialect="postgres").set("name", "John").where_eq("id", 1) + explain_sql = query.explain().build() + + assert "EXPLAIN" in explain_sql.raw_sql.upper() + assert "UPDATE" in explain_sql.raw_sql.upper() + + +def test_delete_explain_build_generates_sql(): + """Test Delete.explain().build() generates EXPLAIN SQL.""" + query = Delete("users", dialect="postgres").where_eq("id", 1) + explain_sql = query.explain().build() + + assert "EXPLAIN" in explain_sql.raw_sql.upper() + assert "DELETE" in explain_sql.raw_sql.upper() + + +def test_select_explain_with_dialect_postgres(): + """Test Select.explain() uses query dialect for PostgreSQL.""" + query = Select("*", dialect="postgres").from_("users") + explain_sql = query.explain(analyze=True).build() + + assert "EXPLAIN (ANALYZE)" in explain_sql.raw_sql + + +def test_select_explain_with_dialect_mysql(): + """Test Select.explain() uses query dialect for MySQL.""" + query = Select("*", dialect="mysql").from_("users") + explain_sql = query.explain(format="json").build() + + assert "FORMAT = JSON" in explain_sql.raw_sql + + +# ----------------------------------------------------------------------------- +# SQLFactory Integration Tests +# ----------------------------------------------------------------------------- + + +def test_sqlfactory_explain_with_string(): + """Test SQLFactory.explain() with SQL string.""" + factory = SQLFactory(dialect="postgres") + explain = factory.explain("SELECT * FROM users") + + assert isinstance(explain, Explain) + + +def test_sqlfactory_explain_with_options(): + """Test SQLFactory.explain() with options.""" + factory = SQLFactory(dialect="postgres") + explain = factory.explain("SELECT * FROM users", analyze=True, verbose=True, format="json") + + assert explain._options.analyze is True + assert explain._options.verbose is True + assert explain._options.format == ExplainFormat.JSON + + +def test_sqlfactory_explain_with_query_builder(): + """Test SQLFactory.explain() with QueryBuilder.""" + factory = SQLFactory(dialect="postgres") + query = factory.select("*").from_("users") + explain = factory.explain(query, analyze=True) # pyright: ignore[reportArgumentType] + + assert isinstance(explain, Explain) + + +def test_sqlfactory_explain_with_sql_object(): + """Test SQLFactory.explain() with SQL object.""" + factory = SQLFactory(dialect="postgres") + sql_obj = SQL("SELECT * FROM users WHERE id = :id", {"id": 1}) + explain = factory.explain(sql_obj, analyze=True) + result = explain.build() + + assert result.named_parameters == {"id": 1} + + +def test_sqlfactory_explain_with_dialect_override(): + """Test SQLFactory.explain() with dialect override.""" + factory = SQLFactory(dialect="postgres") + explain = factory.explain("SELECT * FROM users", dialect="mysql") + + assert explain._dialect == "mysql" + + +def test_sqlfactory_explain_uses_factory_dialect(): + """Test SQLFactory.explain() uses factory dialect when not overridden.""" + factory = SQLFactory(dialect="postgres") + explain = factory.explain("SELECT * FROM users") + + assert explain._dialect == "postgres" + + +def test_sql_global_instance_explain(): + """Test global sql instance has explain() method.""" + explain = sql.explain("SELECT * FROM users") + + assert isinstance(explain, Explain) + + +def test_sql_global_instance_explain_with_builder(): + """Test global sql instance explain() with builder.""" + query = sql.select("*").from_("users") + explain = sql.explain(query, analyze=True) # pyright: ignore[reportArgumentType] + result = explain.build() + + assert "EXPLAIN" in result.raw_sql.upper() + + +# ----------------------------------------------------------------------------- +# SQL Class Integration Tests +# ----------------------------------------------------------------------------- + + +def test_sql_class_explain_method(): + """Test SQL class has explain() method.""" + stmt = SQL("SELECT * FROM users") + + assert hasattr(stmt, "explain") + assert callable(stmt.explain) + + +def test_sql_class_explain_returns_sql_object(): + """Test SQL.explain() returns SQL object.""" + stmt = SQL("SELECT * FROM users") + result = stmt.explain() + + assert isinstance(result, SQL) + + +def test_sql_class_explain_with_analyze(): + """Test SQL.explain() with analyze=True.""" + stmt = SQL("SELECT * FROM users", statement_config=None) + result = stmt.explain(analyze=True) + + assert "EXPLAIN" in result.raw_sql.upper() + + +def test_sql_class_explain_with_verbose(): + """Test SQL.explain() with verbose=True.""" + stmt = SQL("SELECT * FROM users") + result = stmt.explain(verbose=True) + + assert "EXPLAIN" in result.raw_sql.upper() + + +def test_sql_class_explain_with_format(): + """Test SQL.explain() with format.""" + stmt = SQL("SELECT * FROM users") + result = stmt.explain(format="json") + + assert "EXPLAIN" in result.raw_sql.upper() + + +def test_sql_class_explain_preserves_parameters(): + """Test SQL.explain() preserves parameters.""" + stmt = SQL("SELECT * FROM users WHERE id = :id", {"id": 1}) + result = stmt.explain() + + assert result.named_parameters == {"id": 1} + + +def test_sql_class_explain_with_dialect(): + """Test SQL.explain() respects statement dialect.""" + from sqlspec.core.statement import StatementConfig + + config = StatementConfig(dialect="postgres") + stmt = SQL("SELECT * FROM users", statement_config=config) + result = stmt.explain(analyze=True) + + assert "EXPLAIN" in result.raw_sql.upper() + + +# ----------------------------------------------------------------------------- +# Edge Cases and Error Handling Tests +# ----------------------------------------------------------------------------- + + +def test_explain_empty_parameters_dict(): + """Test Explain handles empty parameters dict.""" + sql_obj = SQL("SELECT * FROM users", {}) + explain = Explain(sql_obj) + result = explain.build() + + assert result.named_parameters == {} + + +def test_explain_complex_sql_with_joins(): + """Test Explain handles complex SQL with joins.""" + sql_str = """ + SELECT u.name, o.order_id + FROM users u + JOIN orders o ON u.id = o.user_id + WHERE u.active = true + """ + explain = Explain(sql_str, dialect="postgres") + result = explain.analyze().build() + + assert "EXPLAIN (ANALYZE)" in result.raw_sql + + +def test_explain_with_cte(): + """Test Explain handles SQL with CTEs.""" + sql_str = """ + WITH active_users AS (SELECT * FROM users WHERE active = true) + SELECT * FROM active_users + """ + explain = Explain(sql_str, dialect="postgres") + result = explain.build() + + assert "EXPLAIN" in result.raw_sql.upper() + + +def test_explain_builder_dialect_case_insensitive(): + """Test Explain handles mixed case dialect names.""" + explain = Explain("SELECT 1", dialect="PostgreSQL") + result = explain.to_sql() + + assert "EXPLAIN" in result.upper() + + +def test_explain_options_immutable_through_copy(): + """Test ExplainOptions.copy() does not modify original.""" + original = ExplainOptions(analyze=True) + _ = original.copy(verbose=True) + + assert original.verbose is False + + +def test_explain_builder_from_select_with_subquery(): + """Test Explain handles Select with subquery.""" + subquery = Select("id").from_("users").where("active = true") + main_query = Select("*").from_(subquery, alias="active_users") + explain = main_query.explain() + result = explain.build() + + assert "EXPLAIN" in result.raw_sql.upper() + + +def test_explain_postgres_all_false_booleans(): + """Test PostgreSQL EXPLAIN with all boolean options set to False.""" + options = ExplainOptions(costs=False, buffers=False, timing=False) + result = _build_postgres_explain("SELECT 1", options) + + assert "COSTS FALSE" in result + assert "BUFFERS FALSE" in result + assert "TIMING FALSE" in result + + +def test_explain_builder_has_slots(): + """Test Explain uses __slots__ for memory efficiency.""" + explain = Explain("SELECT 1") + + assert hasattr(explain, "__slots__") + + +def test_normalize_dialect_name_with_dialect_object(): + """Test _normalize_dialect_name with dialect instance (non-string).""" + from sqlglot.dialects import postgres + + dialect_instance = postgres.Postgres() + result = _normalize_dialect_name(dialect_instance) + + assert result == "postgres" + + +def test_explain_construction_invalid_statement_raises(): + """Test Explain raises SQLBuilderError for unsupported statement type.""" + from sqlspec.exceptions import SQLBuilderError + + class UnsupportedType: + pass + + unsupported = UnsupportedType() + + with pytest.raises(SQLBuilderError, match="Cannot resolve statement to SQL"): + Explain(unsupported) # pyright: ignore[reportArgumentType] + + +def test_explain_construction_with_has_expression_and_sql(): + """Test Explain construction from object with expression and sql attributes.""" + from sqlglot import exp as glot_exp + + class MockSQLObject: + expression = glot_exp.Select() + sql = "SELECT * FROM mock_table" + + mock_obj = MockSQLObject() + explain = Explain(mock_obj) # pyright: ignore[reportArgumentType] + + assert "SELECT * FROM mock_table" in explain._statement_sql