Skip to content

feat(sql): add EXPLAIN query plans for SQLAlchemy panel#5

Merged
JacobCoffee merged 4 commits intomainfrom
explain-plans
Nov 27, 2025
Merged

feat(sql): add EXPLAIN query plans for SQLAlchemy panel#5
JacobCoffee merged 4 commits intomainfrom
explain-plans

Conversation

@JacobCoffee
Copy link
Owner

Summary

  • Adds EXPLAIN query plan support for PostgreSQL, SQLite, and MySQL dialects
  • Adds API endpoint /_debug_toolbar/api/explain for executing EXPLAIN queries
  • Adds EXPLAIN button to SQL queries in the toolbar UI that displays execution plans

Changes

  • New ExplainExecutor class with multi-dialect EXPLAIN SQL generation
  • Global SQLAlchemy event listeners for automatic query tracking on all engines
  • Route handlers fallback to plugin.toolbar when app.state is not available
  • Added data-request-id attribute to toolbar div for testing
  • 27 new tests for EXPLAIN functionality
  • Updated example apps to store db_engine in app state

Test plan

  • All 298 tests passing
  • CI checks passing (lint, type-check, fmt, test)
  • Manual testing with PostgreSQL, SQLite databases
  • Verify EXPLAIN button appears for supported queries
  • Verify execution plans are displayed correctly

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings November 27, 2025 08:08
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds EXPLAIN query plan functionality to the SQLAlchemy panel, enabling developers to analyze database query execution plans directly from the debug toolbar. The implementation supports PostgreSQL, SQLite, and MySQL dialects with both standard EXPLAIN and EXPLAIN ANALYZE options.

Key Changes

  • New ExplainExecutor class that generates dialect-specific EXPLAIN SQL and executes queries with proper async/sync engine support
  • API endpoint /_debug_toolbar/api/explain for executing EXPLAIN queries from the UI
  • Enhanced QueryTracker to record query metadata (hash, dialect, EXPLAIN support) and track connections for proper cleanup

Reviewed changes

Copilot reviewed 21 out of 22 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/debug_toolbar/extras/advanced_alchemy/panel.py Core EXPLAIN functionality: ExplainExecutor class, QueryTracker enhancements, global event listener support
src/debug_toolbar/extras/advanced_alchemy/__init__.py Exports new ExplainExecutor, QueryTracker, and get_query_tracker for public API
src/debug_toolbar/litestar/routes/handlers.py New route handlers file with toolbar API/UI controllers, EXPLAIN endpoint, and embedded CSS/JS
src/debug_toolbar/litestar/routes/__init__.py Module initialization exporting create_toolbar_router
src/debug_toolbar/litestar/plugin.py Plugin updates to create toolbar router, register toolbar in app state, and share toolbar instance
src/debug_toolbar/litestar/middleware.py Middleware updated to accept shared toolbar instance and add data-request-id attribute
tests/unit/test_sqlalchemy_panel.py Comprehensive test coverage: 27 new tests for ExplainExecutor and QueryTracker EXPLAIN fields
tests/integration/test_toolbar_routes.py New integration tests for toolbar routes, API endpoints, static assets, and full workflow
tests/integration/test_litestar_middleware.py Updated test to check for data-request-id attribute instead of text content
examples/litestar_advanced_alchemy/app.py Example app updated to store db_engine in app state for EXPLAIN queries
examples/litestar_basic/app.py Import order correction
examples/profiling_panel_example.py f-string removed (cosmetic fix)
package.json Added Biome tooling for JavaScript/CSS linting and formatting
biome.json Biome configuration for frontend code quality
.gitignore Added Node.js/Bun ignore patterns
tests/__init__.py, tests/unit/__init__.py, tests/integration/__init__.py Simplified docstrings
Documentation files Updated API documentation structure for new routes module

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +150 to +154
@staticmethod
def _is_explainable(sql: str) -> bool:
"""Check if a SQL statement can be explained."""
sql_upper = sql.upper().strip()
return sql_upper.startswith(("SELECT", "INSERT", "UPDATE", "DELETE", "WITH"))
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _is_explainable() validation is insufficient for preventing SQL injection. It only checks if the query starts with certain keywords, but doesn't prevent:

  • Multiple statements separated by semicolons (e.g., "SELECT 1; DROP TABLE users;")
  • Comments that could hide malicious SQL
  • Nested statements or SQL that executes additional commands

Consider using a SQL parser library like sqlparse to validate that the SQL is a single, safe statement, or implement more robust validation logic.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix - same rationale as above. The debug toolbar should only be enabled in development with trusted users. Adding a SQL parser like sqlparse would add a dependency for minimal benefit in a dev-only tool.

Comment on lines +259 to +260
except Exception:
return False
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overly broad exception handling without logging. The _setup_global_listeners() function silently swallows all exceptions and returns False, making it difficult to diagnose why listener attachment failed. This could hide import errors, permission issues, or other unexpected problems.

Consider:

  1. Catching specific exceptions (e.g., ImportError, AttributeError)
  2. Logging the exception for debugging purposes
  3. Re-raising critical exceptions that indicate configuration problems

Example:

try:
    from sqlalchemy.engine import Engine as SAEngine
    event.listen(SAEngine, "before_cursor_execute", _tracker.before_cursor_execute)
    event.listen(SAEngine, "after_cursor_execute", _tracker.after_cursor_execute)
    _global_listeners_attached = True
    return True
except ImportError:
    logger.debug("SQLAlchemy not available for global listeners")
    return False
except Exception as e:
    logger.warning(f"Failed to attach global listeners: {e}")
    return False

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix - the silent failure is intentional. If SQLAlchemy isn't available, the panel simply won't track queries rather than crashing. Debug-level logging would be excessive for a dev tool.

Comment on lines +248 to +258
global _global_listeners_attached # noqa: PLW0603
if _global_listeners_attached:
return True

try:
from sqlalchemy.engine import Engine as SAEngine

event.listen(SAEngine, "before_cursor_execute", _tracker.before_cursor_execute)
event.listen(SAEngine, "after_cursor_execute", _tracker.after_cursor_execute)
_global_listeners_attached = True
return True
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition with global state. The _global_listeners_attached flag is checked and set without synchronization. In a multi-threaded or async environment, multiple threads/tasks could simultaneously check this flag, see False, and all attempt to attach listeners, potentially causing duplicate listener registration or race conditions.

While this might not cause immediate problems (SQLAlchemy event listeners can handle duplicates), it could lead to unexpected behavior or performance degradation. Consider using a thread-safe mechanism like threading.Lock() or making the function idempotent by checking if listeners are already attached before adding them.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't fix - the race condition is benign. If duplicate listeners are attached, SQLAlchemy handles this gracefully. Adding thread synchronization would add complexity for negligible benefit in a debug tool.

@github-actions
Copy link
Contributor

github-actions bot commented Nov 27, 2025

PR Preview Action v1.6.3

🚀 View preview at
https://JacobCoffee.github.io/debug-toolbar/pr-preview/pr-5/

Built to branch gh-pages at 2025-11-27 09:01 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Copilot AI review requested due to automatic review settings November 27, 2025 08:25
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

JacobCoffee and others added 2 commits November 27, 2025 02:54
Add ExplainExecutor class to execute EXPLAIN queries across different
database dialects (PostgreSQL, SQLite, MySQL, MariaDB).

Features:
- Multi-dialect EXPLAIN prefix support
- Parameter substitution for valid SQL execution
- Query hash generation for fingerprinting
- Proper error handling with informative messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Add panel_display_depth, panel_display_max_items, and panel_display_max_string
settings to DebugToolbarConfig for controlling nested data rendering.

Defaults:
- panel_display_depth: 10 (was 3)
- panel_display_max_items: 100 (was 20)
- panel_display_max_string: 1000 (was 500)

This prevents "{X keys}" truncation in panel data display.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings November 27, 2025 08:54
UI improvements:
- Add EXPLAIN button to SQL panel queries with modal display
- Add /api/explain POST endpoint for executing EXPLAIN queries
- Improve toolbar layout for top/bottom positions (full width)
- Add position controls separator and theme toggle
- Use Base64 encoding for SQL data attributes (fixes JS escaping)

Example app:
- Add on_startup hook to store db_engine for EXPLAIN queries

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

- Fix import order in example apps (litestar before debug_toolbar)
- Document new panel display config fields in docstring
- Remove unsupported Oracle/MSSQL from DIALECT_EXPLAIN_PREFIX
- Use safer PostgreSQL EXPLAIN without ANALYZE
- Remove unused _dialect_name field from QueryTracker

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@JacobCoffee JacobCoffee merged commit 7abf410 into main Nov 27, 2025
18 checks passed
@JacobCoffee JacobCoffee deleted the explain-plans branch November 27, 2025 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants