├── .cursorignore
├── .dockerignore
├── .env.sample
├── .github
└── workflows
│ └── php-tests.yml
├── .gitignore
├── .php-cs-fixer.dist.php
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── analysis-summary.png
├── group.png
├── groups.png
├── index.png
└── result.png
├── composer.json
├── composer.lock
├── index.php
├── phpstan.neon
├── src
├── AnalyzedDatabase.php
├── Controller
│ ├── AnalysisController.php
│ ├── BaseController.php
│ ├── IndexController.php
│ └── RunController.php
├── LLMFileLogger.php
├── QueryAnalyzer.php
├── QuerySelector.php
├── Result
│ ├── CandidateQuery.php
│ ├── CandidateQueryGroup.php
│ └── CandidateResult.php
├── Service
│ ├── DatabaseQueryExecutor.php
│ └── QueryResultFormatter.php
├── StateDatabase.php
├── Tool
│ ├── PerformanceSchemaQueryTool.php
│ └── QueryTool.php
└── schema
│ └── state_database.sql
└── templates
├── analysis.html.twig
├── base.html.twig
├── run_detail.html.twig
└── runs.html.twig
/.cursorignore:
--------------------------------------------------------------------------------
1 | /var/
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .env
2 | /vendor/
3 | /data/
4 | /var/
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | DATABASE_URL=mysql://user:password@host:port/
2 | ANTHROPIC_API_KEY=sk-ant-xxxxxxxxx
--------------------------------------------------------------------------------
/.github/workflows/php-tests.yml:
--------------------------------------------------------------------------------
1 | name: PHP Tests
2 |
3 | on:
4 | push:
5 | branches: [ main, master ]
6 | pull_request:
7 | branches: [ main, master ]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | phpstan:
12 | name: PHPStan Analysis
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout code
17 | uses: actions/checkout@v3
18 |
19 | - name: Setup PHP
20 | uses: shivammathur/setup-php@v2
21 | with:
22 | php-version: '8.3'
23 | extensions: mbstring, xml, json, zlib
24 | coverage: none
25 |
26 | - name: Install dependencies
27 | run: composer install --prefer-dist --no-progress
28 |
29 | - name: Run PHPStan
30 | run: vendor/bin/phpstan analyse src
31 |
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /var/
2 | /run.bat
3 | /.env
4 | /vendor/
5 | /data/
--------------------------------------------------------------------------------
/.php-cs-fixer.dist.php:
--------------------------------------------------------------------------------
1 | in(__DIR__)
5 | ->exclude(['vendor', 'node_modules'])
6 | ->name('*.php');
7 |
8 | return (new PhpCsFixer\Config())
9 | ->setRules([
10 | '@PSR12' => true,
11 | 'array_syntax' => ['syntax' => 'short'],
12 | 'binary_operator_spaces' => true,
13 | 'blank_line_after_opening_tag' => false,
14 | 'blank_line_before_statement' => [
15 | 'statements' => ['return', 'throw', 'try']
16 | ],
17 | 'blank_line_after_namespace' => true,
18 | 'blank_lines_before_namespace' => true,
19 | 'braces' => [
20 | 'allow_single_line_anonymous_class_with_empty_body' => false,
21 | 'allow_single_line_closure' => false,
22 | 'position_after_anonymous_constructs' => 'same',
23 | 'position_after_control_structures' => 'same',
24 | 'position_after_functions_and_oop_constructs' => 'same',
25 | ],
26 | 'class_attributes_separation' => [
27 | 'elements' => [
28 | 'method' => 'one',
29 | 'property' => 'one',
30 | 'const' => 'one',
31 | ]
32 | ],
33 | 'concat_space' => ['spacing' => 'one'],
34 | 'control_structure_continuation_position' => ['position' => 'same_line'],
35 | 'curly_braces_position' => [
36 | 'classes_opening_brace' => 'same_line',
37 | 'functions_opening_brace' => 'same_line',
38 | 'anonymous_functions_opening_brace' => 'same_line',
39 | 'anonymous_classes_opening_brace' => 'same_line',
40 | ],
41 | 'declare_parentheses' => true,
42 | 'function_declaration' => [
43 | 'closure_function_spacing' => 'one',
44 | ],
45 | 'function_typehint_space' => true,
46 | 'include' => true,
47 | 'indentation_type' => true,
48 | 'method_argument_space' => [
49 | 'on_multiline' => 'ensure_fully_multiline',
50 | 'keep_multiple_spaces_after_comma' => false
51 | ],
52 | 'method_chaining_indentation' => false,
53 | 'multiline_whitespace_before_semicolons' => [
54 | 'strategy' => 'no_multi_line',
55 | ],
56 | 'no_extra_blank_lines' => [
57 | 'tokens' => [
58 | 'extra',
59 | 'throw',
60 | 'use',
61 | 'use_trait',
62 | ]
63 | ],
64 | 'no_spaces_after_function_name' => true,
65 | 'no_spaces_around_offset' => true,
66 | 'no_spaces_inside_parenthesis' => true,
67 | 'no_trailing_whitespace' => true,
68 | 'no_trailing_whitespace_in_comment' => true,
69 | 'operator_linebreak' => [
70 | 'only_booleans' => true,
71 | 'position' => 'beginning',
72 | ],
73 | 'single_line_comment_style' => [
74 | 'comment_types' => ['hash']
75 | ],
76 | 'single_space_after_construct' => true,
77 | 'space_after_semicolon' => true,
78 | 'switch_case_semicolon_to_colon' => true,
79 | 'switch_case_space' => true,
80 | 'ternary_operator_spaces' => true,
81 | 'whitespace_after_comma_in_array' => true,
82 | 'trailing_comma_in_multiline' => true,
83 | ])
84 | ->setIndent(" ")
85 | ->setLineEnding("\n")
86 | ->setFinder($finder);
87 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM php:8.4-cli
2 |
3 | WORKDIR /var/www/html
4 |
5 | RUN apt-get update && apt-get install -y \
6 | git \
7 | curl \
8 | libzip-dev \
9 | unzip \
10 | && docker-php-ext-configure zip \
11 | && docker-php-ext-install -j$(nproc) \
12 | mysqli \
13 | zip \
14 | && apt-get clean \
15 | && rm -rf /var/lib/apt/lists/*
16 |
17 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
18 |
19 | # Configure PHP
20 | RUN echo "memory_limit = 512M" >> /usr/local/etc/php/conf.d/memory-limit.ini \
21 | && echo "max_execution_time = 300" >> /usr/local/etc/php/conf.d/execution-time.ini
22 |
23 | COPY composer.json composer.lock ./
24 |
25 | RUN composer install \
26 | --no-dev \
27 | --no-scripts \
28 | --no-autoloader \
29 | --prefer-dist
30 | COPY . .
31 |
32 | # Create required directories and set permissions
33 | RUN mkdir -p var/cache var/log data \
34 | && chown -R www-data:www-data var data \
35 | && chmod -R 755 var data
36 |
37 | # Complete composer installation with autoloader
38 | RUN composer dump-autoload --optimize --no-dev
39 |
40 | USER www-data
41 |
42 | RUN touch .env
43 |
44 | EXPOSE 8000
45 |
46 | CMD ["php","-S","0.0.0.0:8000"]
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Petr Soukup
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SQL AI Optimizer
2 |
3 | This tool leverages AI to analyze and optimize SQL queries for better performance. It can pinpoint specific queries that are causing performance issues and suggest optimizations.
4 |
5 | You can also specify special instructions for the AI to follow (e.g. "focus on queries consuming memory").
6 |
7 | ## Description
8 |
9 | SQL AI Optimizer is a PHP application that connects to databases, analyzes SQL queries, and uses Anthropic's AI models to suggest optimizations, identify performance bottlenecks, and provide insights to improve database efficiency.
10 |
11 | ## Screenshots
12 |
13 |
14 |
15 |
16 | --------------------------------
17 |
18 |
19 |
20 |
21 |
22 | --------------------------------
23 |
24 |
25 |
26 |
27 |
28 | --------------------------------
29 |
30 |
31 |
32 |
33 |
34 | --------------------------------
35 |
36 |
37 |
38 |
39 |
40 | ## Features
41 |
42 | - Connect to MySQL/MariaDB databases
43 | - Analyze query performance using Performance Schema
44 | - AI-powered query optimization suggestions
45 | - Performance metrics visualization
46 | - Query history tracking
47 | - Export results as offline HTML
48 |
49 | ## Requirements
50 |
51 | - PHP 8.2+
52 | - Composer
53 | - MySQL/MariaDB database with Performance Schema enabled
54 | - Anthropic API key
55 |
56 | ## Security
57 |
58 | This tool runs queries suggested by the LLM on your database. Queries and their results are sent to the LLM. It is important to use a read-only user with limited privileges.
59 |
60 | The LLM is instructed to only get statistical data from the database, but it cannot be guaranteed that it will follow this instruction. If your queries contain sensitive data, it could be sent to the LLM.
61 |
62 | **This tool is LLM powered SQL injection and should be run only locally and disabled after completing the analysis.**
63 |
64 | ## Installation
65 |
66 | 1. Clone the repository:
67 | ```
68 | git clone https://github.com/soukicz/sql-ai-optimizer.git
69 | cd sql-ai-optimizer
70 | ```
71 |
72 | 2. Install dependencies:
73 | ```
74 | composer install --no-dev
75 | ```
76 |
77 | 3. Create a `.env` file in the project root with your [Anthropic API key](https://console.anthropic.com/settings/keys) and database connection string:
78 | ```
79 | ANTHROPIC_API_KEY=your_api_key_here
80 | DATABASE_URL=mysql://user:password@host:port/
81 | ```
82 |
83 | ## Usage
84 |
85 | 1. Start the application:
86 | ```
87 | # with docker
88 | docker run --rm -it --env-file .env -p 8000:8000 $(docker build -q .)
89 |
90 | # without docker
91 | php -S 127.0.0.1:8000
92 | ```
93 |
94 | 2. Connect to your database via SSH tunnel if needed:
95 | ```
96 | ssh -L 33306:your-db-host:3306 user@your-ssh-server
97 | ```
98 |
99 | 3. Access the application at http://127.0.0.1:8000
100 |
101 | ## Project Structure
102 |
103 | This tool is as simple as possible with few dependencies by design. It is accessing production databases and it should be easy to evaluate what it is doing.
104 |
105 | - `src/` - Application source code
106 | - `templates/` - Twig templates for the web interface
107 | - `data/` - Application data including state database
108 | - `var/` - Cache and logs
109 |
110 | ## MySQL Configuration
111 | ### User
112 | Create a user with read-only privileges. It should get access to at least the performance_schema. You will get best results by giving full SELECT privileges to all tables.
113 |
114 | The database must be either production or a copy of production. This tool is checking query history and needs real traffic to provide useful results.
115 |
116 | ```sql
117 | CREATE USER 'user'@'your_ip' IDENTIFIED BY 'password';
118 | GRANT SELECT, SHOW VIEW ON performance_schema.* TO 'user'@'your_ip';
119 | GRANT SELECT, SHOW VIEW ON *.* TO 'user'@'your_ip';
120 | ```
121 |
122 | ### Performance Schema
123 |
124 | - Enable [performance schema](https://dev.mysql.com/doc/refman/8.4/en/performance-schema-quick-start.html)
125 | - Configure [performance_schema_digests_size](https://dev.mysql.com/doc/refman/8.4/en/performance-schema-system-variables.html#sysvar_performance_schema_digests_size) to store more query digests
126 | - Configure [performance_schema_max_digest_length](https://dev.mysql.com/doc/refman/8.4/en/performance-schema-system-variables.html#sysvar_performance_schema_max_digest_length) to see full queries and to avoid merging of results for queries with the same beginning (default is 1024 characters)
127 | - Enable the larger query table to get exact queries for analysis by running `UPDATE performance_schema.setup_consumers SET enabled = 'YES' WHERE name = 'events_statements_history_long';`
128 |
129 |
130 | Performance statistics should be collected for reasonable time before running the optimizer - at least a few hours, ideally few days. You can reset statistics after optimizing queries by running:
131 |
132 | ```sql
133 | CALL sys.ps_truncate_all_tables(FALSE);
134 | ```
135 |
--------------------------------------------------------------------------------
/assets/analysis-summary.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/soukicz/sql-ai-optimizer/034edde4fea31a05d4a605080af40f779346148c/assets/analysis-summary.png
--------------------------------------------------------------------------------
/assets/group.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/soukicz/sql-ai-optimizer/034edde4fea31a05d4a605080af40f779346148c/assets/group.png
--------------------------------------------------------------------------------
/assets/groups.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/soukicz/sql-ai-optimizer/034edde4fea31a05d4a605080af40f779346148c/assets/groups.png
--------------------------------------------------------------------------------
/assets/index.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/soukicz/sql-ai-optimizer/034edde4fea31a05d4a605080af40f779346148c/assets/index.png
--------------------------------------------------------------------------------
/assets/result.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/soukicz/sql-ai-optimizer/034edde4fea31a05d4a605080af40f779346148c/assets/result.png
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "soukicz/sql-ai-optimizer",
3 | "type": "project",
4 | "autoload": {
5 | "psr-4": {
6 | "Soukicz\\SqlAiOptimizer\\": "src/"
7 | }
8 | },
9 | "authors": [
10 | {
11 | "name": "Petr Soukup",
12 | "email": "soukup@simplia.cz"
13 | }
14 | ],
15 | "require": {
16 | "php": "^8.2",
17 | "ext-mysqli": "*",
18 | "ext-sqlite3": "*",
19 | "soukicz/llm": "0.2",
20 | "symfony/framework-bundle": "^7.2",
21 | "symfony/runtime": "^7.2",
22 | "dibi/dibi": "^5.0",
23 | "symfony/dotenv": "^7.2",
24 | "symfony/cache": "^7.2",
25 | "twig/twig": "^3.20",
26 | "league/commonmark": "^2.6",
27 | "spatie/commonmark-highlighter": "^3.0"
28 | },
29 | "config": {
30 | "allow-plugins": {
31 | "symfony/runtime": true
32 | }
33 | },
34 | "require-dev": {
35 | "phpstan/phpstan": "^2.1"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/index.php:
--------------------------------------------------------------------------------
1 | extension('framework', [
26 | 'secret' => 'S0ME_SECRET',
27 | ]);
28 |
29 | if (file_exists(__DIR__ . '/.env')) {
30 | // Load environment variables from .env file
31 | $dotenv = new \Symfony\Component\Dotenv\Dotenv();
32 | $dotenv->loadEnv(__DIR__ . '/.env');
33 | }
34 |
35 | $container->services()
36 | ->load('Soukicz\\SqlAiOptimizer\\', __DIR__ . '/src/*')
37 | ->autowire()
38 | ->autoconfigure();
39 |
40 | $container->services()
41 | ->set(PerformanceSchemaQueryTool::class)
42 | ->arg('$cacheDatabaseResults', false)
43 | ->autowire()
44 | ->autoconfigure();
45 |
46 | $container->services()
47 | ->set(QueryTool::class)
48 | ->arg('$cacheDatabaseResults', false)
49 | ->autowire()
50 | ->autoconfigure();
51 |
52 | $container->services()
53 | ->set(FileCache::class)
54 | ->arg('$cacheDir', __DIR__ . '/var/cache')
55 | ->autowire()
56 | ->autoconfigure();
57 |
58 | $container->services()
59 | ->set(\Symfony\Component\Cache\Adapter\FilesystemAdapter::class)
60 | ->arg('$namespace', '')
61 | ->arg('$defaultLifetime', 0)
62 | ->arg('$directory', __DIR__ . '/var/cache')
63 | ->autowire()
64 | ->autoconfigure();
65 |
66 | $container->services()
67 | ->set(AnthropicClient::class)
68 | ->arg('$apiKey', '%env(ANTHROPIC_API_KEY)%')
69 | ->arg('$cache', new Reference(FileCache::class))
70 | ->autowire()
71 | ->autoconfigure();
72 |
73 | $container->services()
74 | ->set(MarkdownFormatter::class)
75 | ->autowire()
76 | ->autoconfigure();
77 |
78 | $container->services()
79 | ->set(LLMFileLogger::class)
80 | ->arg('$logPath', __DIR__ . '/var/log/llm.md')
81 | ->arg('$formatter', new Reference(MarkdownFormatter::class))
82 | ->autowire()
83 | ->autoconfigure();
84 |
85 | $container->services()
86 | ->set(LLMChainClient::class)
87 | ->arg('$logger', new Reference(LLMFileLogger::class))
88 | ->autowire()
89 | ->autoconfigure();
90 |
91 | $container->services()
92 | ->set(StateDatabase::class)
93 | ->arg('$databasePath', __DIR__ . '/data/state.sqlite')
94 | ->autowire()
95 | ->autoconfigure();
96 |
97 | $container->services()
98 | ->set(\Soukicz\SqlAiOptimizer\Service\QueryResultFormatter::class)
99 | ->autowire()
100 | ->autoconfigure();
101 |
102 | $container->services()
103 | ->set(\Soukicz\SqlAiOptimizer\Service\DatabaseQueryExecutor::class)
104 | ->arg('$cacheTtl', 24 * 60 * 60)
105 | ->autowire()
106 | ->autoconfigure();
107 |
108 | // Register Twig
109 | $container->services()
110 | ->set('twig.loader', \Twig\Loader\FilesystemLoader::class)
111 | ->arg('$paths', [__DIR__ . '/templates'])
112 | ->autowire()
113 | ->autoconfigure();
114 |
115 | $container->services()
116 | ->set(\Twig\Environment::class)
117 | ->arg('$loader', new Reference('twig.loader'))
118 | ->arg('$options', [
119 | 'cache' => __DIR__ . '/var/cache/twig',
120 | 'debug' => '%env(bool:APP_DEBUG)%',
121 | ])
122 | ->autowire()
123 | ->autoconfigure();
124 |
125 | // Register controllers
126 | $container->services()
127 | ->load('Soukicz\\SqlAiOptimizer\\Controller\\', __DIR__ . '/src/Controller/')
128 | ->tag('controller.service_arguments')
129 | ->autowire()
130 | ->autoconfigure();
131 | }
132 |
133 | protected function configureRoutes(RoutingConfigurator $routes): void {
134 | $routes->import(__DIR__ . '/src/Controller/', 'attribute');
135 | }
136 | }
137 |
138 | return static function (array $context) {
139 | return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
140 | };
141 |
--------------------------------------------------------------------------------
/phpstan.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | level: 5
3 | paths:
4 | - src
5 |
--------------------------------------------------------------------------------
/src/AnalyzedDatabase.php:
--------------------------------------------------------------------------------
1 | hostname = $parsedUrl['host'];
19 | $this->port = $parsedUrl['port'] ?? null;
20 | $dbConfig = [
21 | 'driver' => 'mysqli',
22 | 'host' => $parsedUrl['host'],
23 | 'username' => $parsedUrl['user'],
24 | 'password' => $parsedUrl['pass'],
25 | 'database' => ltrim($parsedUrl['path'], '/'),
26 | 'port' => $parsedUrl['port'] ?? null,
27 | 'charset' => 'utf8mb4',
28 | 'lazy' => true,
29 | ];
30 |
31 | $this->connection = new Connection($dbConfig);
32 | }
33 |
34 | public function getHostnameWithPort(): string {
35 | if ($this->port) {
36 | return $this->hostname . ':' . $this->port;
37 | }
38 |
39 | return $this->hostname;
40 | }
41 |
42 | public function getConnection(): Connection {
43 | return $this->connection;
44 | }
45 |
46 | public function getQueryText(string $digest, string $schema): ?string {
47 | foreach (['events_statements_history', 'events_statements_history_long'] as $table) {
48 | $sql = $this->connection->query('SELECT sql_text FROM performance_schema.%n WHERE digest=%s', $table, $digest, ' AND current_schema = %s', $schema)->fetchSingle();
49 | if ($sql) {
50 | return $sql;
51 | }
52 | }
53 |
54 | return null;
55 | }
56 |
57 | public function getQueryTexts(array $digests): array {
58 | $sqls = [];
59 |
60 | foreach (['events_statements_history', 'events_statements_history_long'] as $table) {
61 | $list = $this->connection->query('SELECT sql_text,digest,current_schema FROM performance_schema.%n', $table, ' WHERE digest IN (%s)', $digests)->fetchAll();
62 | foreach ($list as $item) {
63 | $sqls[] = [
64 | 'sql_text' => $item['sql_text'],
65 | 'digest' => $item['digest'],
66 | 'current_schema' => $item['current_schema'],
67 | ];
68 | }
69 | }
70 |
71 | return $sqls;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/Controller/AnalysisController.php:
--------------------------------------------------------------------------------
1 | request->all('query_ids'));
34 |
35 | $run = $this->stateDatabase->getRun($id);
36 | if (!$run) {
37 | throw new \Exception('Run not found');
38 | }
39 |
40 | $promises = [];
41 | foreach ($queryIds as $queryId) {
42 | $queryData = $this->stateDatabase->getQuery($queryId);
43 | $queryObject = new CandidateQuery(
44 | schema: $queryData['schema'],
45 | digest: $queryData['digest'],
46 | normalizedQuery: $queryData['normalized_query'],
47 | impactDescription: $queryData['impact_description'],
48 | );
49 |
50 | $promises[] = $this->queryAnalyzer->analyzeQuery((int)$queryId, $queryData['real_query'], $queryObject, $run['use_real_query'], $run['use_database_access']);
51 | }
52 |
53 | // Process 5 promises concurrently
54 | Each::ofLimit($promises, 5)->wait();
55 |
56 | return new JsonResponse([
57 | 'url' => $this->router->generate('query.detail', ['queryId' => $queryIds[0]]),
58 | ]);
59 | }
60 |
61 | #[Route('/run/{id}/continue', name: 'run.continue', methods: ['POST'])]
62 | public function continuePrompt(Request $request, int $id): Response {
63 | $queryData = $this->stateDatabase->getQuery($id);
64 | $run = $this->stateDatabase->getRun($queryData['run_id']);
65 | /** @var \Soukicz\Llm\LLMResponse $response */
66 | $response = $this->queryAnalyzer->continueConversation(
67 | conversation: LLMConversation::fromJson(json_decode($queryData['llm_conversation'], true)),
68 | prompt: $request->request->get('input'),
69 | useDatabaseAccess: $run['use_database_access']
70 | )->wait();
71 |
72 | $this->stateDatabase->updateConversation(
73 | queryId: $id,
74 | conversation: $response->getConversation(),
75 | conversationMarkdown: $this->markdownFormatter->responseToMarkdown($response)
76 | );
77 |
78 | return new JsonResponse([
79 | 'url' => $this->router->generate('query.detail', ['queryId' => $id]),
80 | ]);
81 | }
82 |
83 | #[Route('/query/{queryId}', name: 'query.detail')]
84 | public function queryDetail(int $queryId, Request $request): Response {
85 | $query = $this->stateDatabase->getQuery($queryId);
86 | if (!$query) {
87 | throw new \Exception('Query not found');
88 | }
89 |
90 | $group = $this->stateDatabase->getGroup($query['group_id']);
91 |
92 | $conversation = LLMConversation::fromJson(json_decode($query['llm_conversation'], true));
93 |
94 | $messages = [];
95 | $firstUser = true;
96 | foreach ($conversation->getMessages() as $message) {
97 | if (!$message->isAssistant() && !$message->isUser()) {
98 | continue;
99 | }
100 | if ($message->isUser() && $firstUser) {
101 | $firstUser = false;
102 | continue;
103 | }
104 |
105 | if ($message->isUser()) {
106 | foreach ($message->getContents() as $content) {
107 | if ($content instanceof LLMMessageText) {
108 | $messages[] = [
109 | 'role' => 'user',
110 | 'content' => nl2br(htmlspecialchars($content->getText(), ENT_QUOTES)),
111 | ];
112 | }
113 | }
114 | } else {
115 | $onlyText = true;
116 |
117 | foreach ($message->getContents() as $content) {
118 | if ($content instanceof LLMMessageReasoning) {
119 | continue;
120 | }
121 |
122 | if (!($content instanceof LLMMessageText)) {
123 | $onlyText = false;
124 | break;
125 | }
126 | }
127 | if ($onlyText) {
128 | foreach ($message->getContents() as $content) {
129 | if ($content instanceof LLMMessageText) {
130 | $messages[] = [
131 | 'role' => 'assistant',
132 | 'content' => $this->renderMarkdownWithHighlighting($content->getText()),
133 | ];
134 | }
135 | }
136 | }
137 | }
138 | }
139 |
140 | if (empty($query['real_query'])) {
141 | $sql = Helpers::dump($query['normalized_query'], true);
142 | } else {
143 | $sql = Helpers::dump($query['real_query'], true);
144 | }
145 | $sql = preg_replace('/^
]*>|<\/pre>$/', '', $sql); 146 | 147 | $isExport = $request->query->has('export'); 148 | $templateVars = [ 149 | 'query' => $query, 150 | 'group' => $group, 151 | 'sql' => $sql, 152 | 'messages' => $messages, 153 | 'backToRunUrl' => $this->router->generate('run.detail', ['id' => $query['run_id']]), 154 | 'continueConversationUrl' => $this->router->generate('run.continue', ['id' => $queryId]), 155 | 'exportUrl' => $this->router->generate('query.detail', ['queryId' => $queryId, 'export' => 1]), 156 | ]; 157 | 158 | if ($isExport) { 159 | $templateVars['export'] = true; 160 | 161 | // Pass zip_export flag if present 162 | if ($request->query->has('zip_export')) { 163 | $templateVars['zip_export'] = true; 164 | } 165 | 166 | $content = $this->twig->render('analysis.html.twig', $templateVars); 167 | 168 | // Only set content disposition for direct download requests, not for ZIP exports 169 | if (!$request->query->has('zip_export')) { 170 | $response = new Response($content); 171 | $response->headers->set('Content-Type', 'text/html'); 172 | $response->headers->set('Content-Disposition', 'attachment; filename="query-' . $queryId . '-export.html"'); 173 | 174 | return $response; 175 | } 176 | 177 | return new Response($content); 178 | } 179 | 180 | return new Response($this->twig->render('analysis.html.twig', $templateVars)); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Controller/BaseController.php: -------------------------------------------------------------------------------- 1 | 'allow', 19 | 'allow_unsafe_links' => false, 20 | ]); 21 | 22 | // Add the core CommonMark rules 23 | $environment->addExtension(new CommonMarkCoreExtension()); 24 | 25 | // Add the GFM extensions 26 | $environment->addExtension(new TableExtension()); 27 | $environment->addExtension(new StrikethroughExtension()); 28 | $environment->addExtension(new TaskListExtension()); 29 | 30 | // Create the converter 31 | $converter = new MarkdownConverter($environment); 32 | 33 | // Convert markdown to HTML 34 | return $converter->convert($markdown)->getContent(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Controller/IndexController.php: -------------------------------------------------------------------------------- 1 | stateDatabase->getRuns(); 22 | 23 | return new Response( 24 | $this->twig->render('runs.html.twig', [ 25 | 'runs' => $runs, 26 | ]) 27 | ); 28 | } 29 | 30 | #[Route('/run/{id}/delete', name: 'delete_run', methods: ['POST'])] 31 | public function deleteRun(int $id, UrlGeneratorInterface $urlGenerator): Response { 32 | $this->stateDatabase->deleteRun($id); 33 | 34 | return new RedirectResponse($urlGenerator->generate('index')); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Controller/RunController.php: -------------------------------------------------------------------------------- 1 | stateDatabase->getRun($id); 32 | 33 | if (!$run) { 34 | return new RedirectResponse($this->router->generate('index')); 35 | } 36 | 37 | $groups = $this->stateDatabase->getGroupsByRunId($id); 38 | $queries = $this->stateDatabase->getQueriesByRunId($id); 39 | $queries = array_map(function ($query) { 40 | $query = (array)$query; 41 | $query['normalized_query_formatted'] = Helpers::dump($query['normalized_query'], true); 42 | $query['normalized_query_formatted'] = preg_replace('/^]*>|<\/pre>$/', '', $query['normalized_query_formatted']); 43 | 44 | return $query; 45 | }, $queries); 46 | 47 | $missingSqlCount = 0; 48 | foreach ($queries as $query) { 49 | if (empty($query['real_query'])) { 50 | $missingSqlCount++; 51 | } 52 | } 53 | 54 | $specialInstructions = $run['input']; 55 | if (!empty($specialInstructions)) { 56 | $specialInstructions = nl2br(htmlspecialchars($specialInstructions)); 57 | } 58 | 59 | $isExport = $request->query->has('export'); 60 | $templateVars = [ 61 | 'summary' => $this->renderMarkdownWithHighlighting($run['output']), 62 | 'run' => $run, 63 | 'groups' => $groups, 64 | 'queries' => $queries, 65 | 'missingSqlCount' => $missingSqlCount, 66 | 'specialInstructions' => $specialInstructions, 67 | ]; 68 | 69 | if ($isExport) { 70 | if ($request->query->get('format') === 'zip') { 71 | return $this->exportAsZip($id, $run, $groups, $queries, $templateVars); 72 | } 73 | 74 | $templateVars['export'] = true; 75 | $content = $this->twig->render('run_detail.html.twig', $templateVars); 76 | 77 | $response = new Response($content); 78 | $response->headers->set('Content-Type', 'text/html'); 79 | $response->headers->set('Content-Disposition', 'attachment; filename="run-' . $id . '-export.html"'); 80 | 81 | return $response; 82 | } 83 | 84 | return new Response( 85 | $this->twig->render('run_detail.html.twig', $templateVars) 86 | ); 87 | } 88 | 89 | /** 90 | * Export run details and all queries as a ZIP file 91 | */ 92 | private function exportAsZip(int $id, array $run, array $groups, array $queries, array $templateVars): Response { 93 | $tempDir = sys_get_temp_dir() . '/sql-optimizer-export-' . uniqid(); 94 | if (!is_dir($tempDir)) { 95 | mkdir($tempDir, 0777, true); 96 | } 97 | 98 | // Add export flag and ZIP export specific flag 99 | $templateVars['export'] = true; 100 | $templateVars['zip_export'] = true; 101 | 102 | // Render main run detail page 103 | $content = $this->twig->render('run_detail.html.twig', $templateVars); 104 | file_put_contents($tempDir . '/index.html', $content); 105 | 106 | // Collect queries with analyzed data 107 | $analyzedQueries = []; 108 | foreach ($queries as $query) { 109 | if (!empty($query['llm_conversation'])) { 110 | $analyzedQueries[] = $query; 111 | } 112 | } 113 | 114 | // Create a mock request for use with AnalysisController 115 | $request = new Request(); 116 | $request->query->set('export', '1'); 117 | $request->query->set('zip_export', '1'); 118 | 119 | // Export each analyzed query using AnalysisController 120 | foreach ($analyzedQueries as $query) { 121 | // Get query content using the AnalysisController 122 | $response = $this->analysisController->queryDetail($query['id'], $request); 123 | 124 | // Modify the content to use local URLs for the ZIP file 125 | $queryContent = $response->getContent(); 126 | 127 | // Replace the backToRunUrl with local reference 128 | $queryContent = str_replace( 129 | 'href="' . $this->router->generate('run.detail', ['id' => $query['run_id']]) . '"', 130 | 'href="index.html"', 131 | $queryContent 132 | ); 133 | 134 | // Save to file 135 | file_put_contents($tempDir . '/query' . $query['id'] . '.html', $queryContent); 136 | } 137 | 138 | // Create ZIP file 139 | $zipFile = $tempDir . '/export.zip'; 140 | $zip = new ZipArchive(); 141 | if ($zip->open($zipFile, ZipArchive::CREATE) !== true) { 142 | throw new \Exception("Cannot create ZIP file"); 143 | } 144 | 145 | // Add all files to ZIP 146 | $dir = new \RecursiveDirectoryIterator($tempDir, \RecursiveDirectoryIterator::SKIP_DOTS); 147 | $iterator = new \RecursiveIteratorIterator($dir); 148 | foreach ($iterator as $file) { 149 | // Skip the ZIP file itself 150 | if ($file->getPathname() === $zipFile) { 151 | continue; 152 | } 153 | 154 | // Add file to ZIP with path relative to temp directory 155 | $relativePath = substr($file->getPathname(), strlen($tempDir) + 1); 156 | $zip->addFile($file->getPathname(), $relativePath); 157 | } 158 | 159 | $zip->close(); 160 | 161 | // Create response with ZIP file 162 | $response = new Response(file_get_contents($zipFile)); 163 | $response->headers->set('Content-Type', 'application/zip'); 164 | $response->headers->set('Content-Disposition', 'attachment; filename="run-' . $id . '-export.zip"'); 165 | 166 | // Clean up temporary files (optional) 167 | $this->removeDirectory($tempDir); 168 | 169 | return $response; 170 | } 171 | 172 | /** 173 | * Recursively remove a directory and its contents 174 | */ 175 | private function removeDirectory(string $dir): void { 176 | if (!is_dir($dir)) { 177 | return; 178 | } 179 | 180 | $objects = scandir($dir); 181 | foreach ($objects as $object) { 182 | if ($object === '.' || $object === '..') { 183 | continue; 184 | } 185 | 186 | $path = $dir . '/' . $object; 187 | if (is_dir($path)) { 188 | $this->removeDirectory($path); 189 | } else { 190 | unlink($path); 191 | } 192 | } 193 | 194 | rmdir($dir); 195 | } 196 | 197 | #[Route('/new-run', name: 'run.new', methods: ['POST'])] 198 | public function newRun(Request $request): Response { 199 | $results = $this->querySelector->getCandidateQueries($request->request->get('input')); 200 | 201 | $useRealQuery = $request->request->getBoolean('use_real_query', false); 202 | 203 | $this->stateDatabase->getConnection()->begin(); 204 | $runId = $this->stateDatabase->createRun( 205 | $request->request->get('input'), 206 | $this->analyzedDatabase->getHostnameWithPort(), 207 | $results->getDescription(), 208 | $useRealQuery, 209 | $request->request->getBoolean('use_database_access', false), 210 | $results->getConversation(), 211 | $results->getFormattedConversation() 212 | ); 213 | 214 | foreach ($results->getGroups() as $group) { 215 | $groupId = $this->stateDatabase->createGroup($runId, $group->getName(), $group->getDescription()); 216 | 217 | foreach ($group->getQueries() as $query) { 218 | if (empty($query->getSchema()) || $query->getSchema() === 'NULL' || $query->getSchema() === 'unknown') { 219 | continue; 220 | } 221 | 222 | $rawSql = $this->analyzedDatabase->getQueryText($query->getDigest(), $query->getSchema()); 223 | 224 | $this->stateDatabase->createQuery( 225 | runId: $runId, 226 | groupId: $groupId, 227 | digest: $query->getDigest(), 228 | normalizedQuery: $query->getNormalizedQuery(), 229 | realQuery: $rawSql, 230 | schema: $query->getSchema(), 231 | impactDescription: $query->getImpactDescription() 232 | ); 233 | } 234 | } 235 | 236 | $this->stateDatabase->getConnection()->commit(); 237 | 238 | return new JsonResponse([ 239 | 'url' => '/run/' . $runId . '#first', 240 | ]); 241 | } 242 | 243 | #[Route('/run/{id}/fetch-queries', name: 'run.fetch-queries')] 244 | public function runFetchQueries(int $id): Response { 245 | $run = $this->stateDatabase->getRun($id); 246 | if (!$run) { 247 | return new JsonResponse([ 248 | 'error' => 'Run not found', 249 | ], 404); 250 | } 251 | 252 | $digests = []; 253 | $queries = []; 254 | $totalQueriesCount = $this->stateDatabase->getQueriesCount($id); 255 | foreach ($this->stateDatabase->getQueriesWithoutRealQuery($id) as $query) { 256 | if (!isset($digests[$query['digest']])) { 257 | $digests[$query['digest']] = []; 258 | } 259 | 260 | $digests[$query['digest']][] = $query['id']; 261 | $queries[$query['id']] = $query['schema']; 262 | } 263 | 264 | if (!empty($digests)) { 265 | foreach ($this->analyzedDatabase->getQueryTexts(array_keys($digests)) as $sql) { 266 | if (isset($digests[$sql['digest']])) { 267 | foreach ($digests[$sql['digest']] as $i => $id) { 268 | if ($queries[$id] === $sql['current_schema']) { 269 | $this->stateDatabase->setRealQuery($id, $sql['sql_text']); 270 | unset($queries[$id]); 271 | unset($digests[$sql['digest']][$i]); 272 | } 273 | } 274 | } 275 | } 276 | } 277 | 278 | return new JsonResponse([ 279 | 'totalQueriesCount' => $totalQueriesCount, 280 | 'missingQueriesCount' => count($queries), 281 | ]); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/LLMFileLogger.php: -------------------------------------------------------------------------------- 1 | logPath, $this->formatter->responseToMarkdown($request)); 20 | } 21 | 22 | public function requestFinished(LLMResponse $response): void { 23 | file_put_contents($this->logPath, $this->formatter->responseToMarkdown($response)); 24 | } 25 | } -------------------------------------------------------------------------------- /src/QueryAnalyzer.php: -------------------------------------------------------------------------------- 1 | analyzedDatabase->getQueryText($candidateQuery->getDigest(), $candidateQuery->getSchema()); 36 | if ($rawSql) { 37 | $this->stateDatabase->setRealQuery( 38 | queryId: $queryId, 39 | sql: $rawSql 40 | ); 41 | } 42 | } 43 | 44 | $explainJson = null; 45 | if ($rawSql) { 46 | $this->analyzedDatabase->getConnection()->query('USE %n', $candidateQuery->getSchema()); 47 | 48 | try { 49 | $explainJson = $this->analyzedDatabase->getConnection() 50 | ->query('EXPLAIN format=json %sql', $rawSql) 51 | ->fetchSingle(); 52 | } catch (DriverException) { 53 | $explainJson = null; 54 | } 55 | } 56 | 57 | if ($rawSql && $useRealQuery) { 58 | $promptSql = $rawSql; 59 | } else { 60 | $promptSql = $candidateQuery->getNormalizedQuery(); 61 | } 62 | 63 | $prompt = <<analyzedDatabase->getConnection()->query('USE %n', $candidateQuery->getSchema()); 103 | $actualTables = $this->analyzedDatabase->getConnection() 104 | ->query('SHOW TABLES')->fetchAll(); 105 | $actualTableMap = []; 106 | foreach ($actualTables as $tableRow) { 107 | $tableName = array_values((array)$tableRow)[0]; // Get the table name from the result 108 | $actualTableMap[strtolower($tableName)] = $tableName; // Store with lowercase key for case-insensitive lookup 109 | } 110 | 111 | foreach ($this->getTablesFromSelectQuery($promptSql) as $extractedTable) { 112 | // Find the correct case-sensitive table name 113 | $lookupKey = strtolower($extractedTable); 114 | if (!isset($actualTableMap[$lookupKey])) { 115 | continue; // Skip if table doesn't exist in the database 116 | } 117 | 118 | $table = $actualTableMap[$lookupKey]; // Use the correctly cased table name 119 | 120 | $schema = $this->analyzedDatabase->getConnection() 121 | ->query('SHOW CREATE TABLE %n', $table)->fetch()['Create Table']; 122 | 123 | $prompt .= "\n\n#### $table\n```\n$schema\n```\n"; 124 | 125 | // Add table indexes information 126 | $indexes = $this->analyzedDatabase->getConnection() 127 | ->query('SHOW INDEX FROM %n', $table)->fetchAll(); 128 | 129 | if (!empty($indexes)) { 130 | $prompt .= "\n#### Indexes for $table\n```\n"; 131 | 132 | // Group indexes by name to handle multi-column indexes 133 | $groupedIndexes = []; 134 | foreach ($indexes as $index) { 135 | $indexName = $index['Key_name']; 136 | if (!isset($groupedIndexes[$indexName])) { 137 | $groupedIndexes[$indexName] = [ 138 | 'name' => $indexName, 139 | 'columns' => [], 140 | 'unique' => !$index['Non_unique'], 141 | 'index_type' => $index['Index_type'], 142 | ]; 143 | } 144 | // Sort columns by their position in the index 145 | $groupedIndexes[$indexName]['columns'][$index['Seq_in_index']] = $index['Column_name']; 146 | } 147 | 148 | // Generate SQL for each index 149 | foreach ($groupedIndexes as $index) { 150 | // Skip PRIMARY key as it's already in CREATE TABLE statement 151 | if ($index['name'] === 'PRIMARY') { 152 | continue; 153 | } 154 | 155 | // Sort columns by position 156 | ksort($index['columns']); 157 | $columnList = implode(', ', $index['columns']); 158 | 159 | // Build the CREATE INDEX statement 160 | $uniqueKeyword = $index['unique'] ? 'UNIQUE ' : ''; 161 | $prompt .= "ALTER TABLE `$table` ADD {$uniqueKeyword}INDEX `{$index['name']}` ($columnList) USING {$index['index_type']};\n"; 162 | } 163 | $prompt .= "```\n"; 164 | 165 | $indexStats = $this->databaseQueryExecutor->executeQuery($candidateQuery->getSchema(), "SHOW INDEX FROM $table"); 166 | $prompt .= "\n\n#### SHOW INDEX FROM $table\n\n$indexStats\n\n"; 167 | } 168 | } 169 | 170 | $prompt .= << getSchema()} 175 | 176 | Query digest: {$candidateQuery->getDigest()} 177 | 178 | EOT; 179 | 180 | return $this->sendConversation(new LLMConversation([ 181 | LLMMessage::createFromUser([ 182 | new LLMMessageText($prompt), 183 | ]), 184 | ]), $useDatabaseAccess) 185 | ->then(function (LLMResponse $response) use ($queryId) { 186 | $this->stateDatabase->updateConversation( 187 | queryId: $queryId, 188 | conversation: $response->getConversation(), 189 | conversationMarkdown: $this->markdownFormatter->responseToMarkdown($response) 190 | ); 191 | }); 192 | } 193 | 194 | private function sendConversation(LLMConversation $conversation, bool $useDatabaseAccess): PromiseInterface { 195 | $tools = []; 196 | if ($useDatabaseAccess) { 197 | $tools[] = $this->queryTool; 198 | } 199 | 200 | $request = new LLMRequest( 201 | model: new AnthropicClaude37Sonnet(AnthropicClaude37Sonnet::VERSION_20250219), 202 | conversation: $conversation, 203 | temperature: 1.0, 204 | maxTokens: 50_000, 205 | reasoningConfig: new ReasoningBudget(30_000), 206 | tools: $tools 207 | ); 208 | 209 | return $this->llmChainClient->runAsync( 210 | client: $this->llmClient, 211 | request: $request, 212 | ); 213 | } 214 | 215 | public function continueConversation(LLMConversation $conversation, string $prompt, bool $useDatabaseAccess): PromiseInterface { 216 | $newConversation = $conversation->withMessage(LLMMessage::createFromUser([new LLMMessageText($prompt)])); 217 | 218 | return $this->sendConversation($newConversation, $useDatabaseAccess); 219 | } 220 | 221 | /** 222 | * Extract all table names from a SQL SELECT query. 223 | * 224 | * @param string $sql The SQL SELECT query. 225 | * @return array An array of unique table names found in the query. 226 | */ 227 | private function getTablesFromSelectQuery($sql) { 228 | // Normalize whitespace 229 | $sql = preg_replace('/\s+/', ' ', trim($sql)); 230 | 231 | // This regex looks for: 232 | // (?:FROM|JOIN) - non-capturing group to match FROM or JOIN 233 | // \s+ - one or more whitespace characters 234 | // ([a-zA-Z0-9_.`]+) - captures table name which can include letters, numbers, underscore, dot, or backticks 235 | // 236 | // Note: If you expect quoted identifiers with double quotes, you may need to adjust the pattern. 237 | $pattern = '/(?:FROM|JOIN)\s+([a-zA-Z0-9_.`]+)/i'; 238 | 239 | // Find all matches 240 | preg_match_all($pattern, $sql, $matches); 241 | 242 | // $matches[1] should hold the captured table names 243 | $tables = $matches[1]; 244 | 245 | // Clean up backticks, if any 246 | $tables = array_map(function ($table) { 247 | // Remove backticks around the table name (e.g. `schema`.`table` => schema.table) 248 | return str_replace('`', '', $table); 249 | }, $tables); 250 | 251 | // Ensure they are unique 252 | $tables = array_unique($tables); 253 | 254 | // Reindex and return 255 | return array_values($tables); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/QuerySelector.php: -------------------------------------------------------------------------------- 1 | performanceSchemaQueryTool, 34 | ]; 35 | 36 | $submitInputSchema = [ 37 | 'type' => 'object', 38 | 'required' => ['queries', 'group_name', 'group_description'], 39 | 'properties' => [ 40 | 'group_name' => [ 41 | 'type' => 'string', 42 | 'description' => 'Group name', 43 | ], 44 | 'group_description' => [ 45 | 'type' => 'string', 46 | 'description' => 'Description of performance impact type of the group', 47 | ], 48 | 'queries' => [ 49 | 'type' => 'array', 50 | 'description' => 'Array of query digests to optimize (min 1, max 20)', 51 | 'minItems' => 1, 52 | 'maxItems' => 20, 53 | 'items' => [ 54 | 'type' => 'object', 55 | 'required' => ['digest', 'query_sample', 'schema', 'reason'], 56 | 'properties' => [ 57 | 'digest' => [ 58 | 'type' => 'string', 59 | 'description' => 'The query digest hash from performance_schema', 60 | ], 61 | 'query_sample' => [ 62 | 'type' => 'string', 63 | 'description' => 'The query text from performance_schema', 64 | ], 65 | 'schema' => [ 66 | 'type' => 'string', 67 | 'description' => 'The database schema the query operates on', 68 | ], 69 | 'reason' => [ 70 | 'type' => 'string', 71 | 'description' => 'Explanation of why this query is worth optimizing - formulate it in a way that it will be obvious if mentioned numbers are about a single query or total for all queries in the group', 72 | ], 73 | ], 74 | ], 75 | ], 76 | ], 77 | ]; 78 | 79 | $tools[] = new CallbackToolDefinition( 80 | name: 'submit_selection', 81 | description: 'Submit your selection of 20 most expensive queries', 82 | inputSchema: $submitInputSchema, 83 | handler: function (array $input) use (&$groups): string { 84 | $groups[] = $input; 85 | 86 | return 'Selection submitted'; 87 | } 88 | ); 89 | 90 | $prompt = << llmChainClient->run( 108 | client: $this->llmClient, 109 | request: new LLMRequest( 110 | model: new AnthropicClaude37Sonnet(AnthropicClaude37Sonnet::VERSION_20250219), 111 | conversation: $conversation, 112 | tools: $tools, 113 | temperature: 1.0, 114 | maxTokens: 50_000, 115 | reasoningConfig: new ReasoningBudget(20_000) 116 | ), 117 | ); 118 | 119 | $resultGroups = []; 120 | foreach ($groups as $group) { 121 | $resultGroups[] = new CandidateQueryGroup( 122 | name: $group['group_name'], 123 | description: $group['group_description'], 124 | queries: array_map(fn (array $query) => new CandidateQuery( 125 | schema: $query['schema'], 126 | digest: $query['digest'], 127 | normalizedQuery: $query['query_sample'], 128 | impactDescription: $query['reason'], 129 | ), $group['queries']), 130 | ); 131 | } 132 | 133 | return new CandidateResult( 134 | description: $response->getLastText(), 135 | conversation: $conversation, 136 | groups: $resultGroups, 137 | formattedConversation: $this->markdownFormatter->responseToMarkdown($response) 138 | ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Result/CandidateQuery.php: -------------------------------------------------------------------------------- 1 | schema; 16 | } 17 | 18 | public function getDigest(): string { 19 | return $this->digest; 20 | } 21 | 22 | public function getImpactDescription(): string { 23 | return $this->impactDescription; 24 | } 25 | 26 | public function getNormalizedQuery(): string { 27 | return $this->normalizedQuery; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Result/CandidateQueryGroup.php: -------------------------------------------------------------------------------- 1 | name; 15 | } 16 | 17 | public function getDescription(): string { 18 | return $this->description; 19 | } 20 | 21 | /** 22 | * @return CandidateQuery[] 23 | */ 24 | public function getQueries(): array { 25 | return $this->queries; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Result/CandidateResult.php: -------------------------------------------------------------------------------- 1 | description; 18 | } 19 | 20 | /** 21 | * @return CandidateQueryGroup[] 22 | */ 23 | public function getGroups(): array { 24 | return $this->groups; 25 | } 26 | 27 | public function getConversation(): LLMConversation { 28 | return $this->conversation; 29 | } 30 | 31 | public function getFormattedConversation(): string { 32 | return $this->formattedConversation; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Service/DatabaseQueryExecutor.php: -------------------------------------------------------------------------------- 1 | doExecuteQuery($schema, $query); 28 | } 29 | 30 | $cacheKey = 'query_' . md5($schema . '_' . $query); 31 | 32 | return $this->cache->get($cacheKey, function (ItemInterface $item) use ($schema, $query, $maxRows) { 33 | $item->expiresAfter($this->cacheTtl); 34 | 35 | return $this->doExecuteQuery($schema, $query, $maxRows); 36 | }); 37 | } 38 | 39 | /** 40 | * Execute the query without caching 41 | * 42 | * @param string $query The SQL query to execute 43 | * @return string The query result as a formatted string 44 | */ 45 | private function doExecuteQuery(string $schema, string $query, ?int $maxRows = null): string { 46 | try { 47 | $connection = $this->database->getConnection(); 48 | $connection->query('USE %n', $schema); 49 | $rows = $connection->query($query)->fetchAll(); 50 | 51 | $totalRows = count($rows); 52 | if (isset($maxRows) && $totalRows > $maxRows) { 53 | return "Note: Query returned $totalRows rows. Only first $maxRows rows are displayed.\n\n" . 54 | $this->formatter->formatAsMarkdownTable(array_slice($rows, 0, $maxRows)); 55 | } 56 | 57 | return $this->formatter->formatAsMarkdownTable($rows); 58 | } catch (\Exception $e) { 59 | return "Error: " . $e->getMessage(); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Service/QueryResultFormatter.php: -------------------------------------------------------------------------------- 1 | databasePath = $databasePath; 18 | $this->connect(); 19 | } 20 | 21 | private function connect(): void { 22 | $needsInitialization = !file_exists($this->databasePath); 23 | 24 | if (!is_dir(dirname($this->databasePath))) { 25 | mkdir(dirname($this->databasePath), 0777, true); 26 | } 27 | 28 | $this->connection = new Connection([ 29 | 'driver' => 'sqlite3', 30 | 'database' => $this->databasePath, 31 | ]); 32 | 33 | if ($needsInitialization) { 34 | $this->initializeDatabase(); 35 | } 36 | } 37 | 38 | private function initializeDatabase(): void { 39 | $sql = file_get_contents(__DIR__ . '/schema/state_database.sql'); 40 | $statements = explode(';', $sql); 41 | 42 | foreach ($statements as $statement) { 43 | $statement = trim($statement); 44 | if (!empty($statement)) { 45 | $this->connection->query($statement); 46 | } 47 | } 48 | } 49 | 50 | public function getConnection(): Connection { 51 | return $this->connection; 52 | } 53 | 54 | public function createRun( 55 | ?string $input, 56 | string $hostname, 57 | string $output, 58 | bool $useRealQuery, 59 | bool $useDatabaseAccess, 60 | LLMConversation $conversation, 61 | string $conversationMarkdown 62 | ): int { 63 | $this->connection->query('INSERT INTO run', [ 64 | 'input' => $input, 65 | 'hostname' => $hostname, 66 | 'output' => $output, 67 | 'use_real_query' => $useRealQuery, 68 | 'use_database_access' => $useDatabaseAccess, 69 | 'llm_conversation' => json_encode($conversation->jsonSerialize(), JSON_THROW_ON_ERROR), 70 | 'llm_conversation_markdown' => $conversationMarkdown, 71 | ]); 72 | 73 | return $this->connection->getInsertId(); 74 | } 75 | 76 | public function createGroup(int $runId, string $name, string $description): int { 77 | $this->connection->query('INSERT INTO `group`', [ 78 | 'run_id' => $runId, 79 | 'name' => $name, 80 | 'description' => $description, 81 | ]); 82 | 83 | return $this->connection->getInsertId(); 84 | } 85 | 86 | public function createQuery( 87 | int $runId, 88 | int $groupId, 89 | string $digest, 90 | string $schema, 91 | string $normalizedQuery, 92 | ?string $realQuery, 93 | string $impactDescription 94 | ): void { 95 | $this->connection->query('INSERT INTO query', [ 96 | 'run_id' => $runId, 97 | 'digest' => $digest, 98 | 'group_id' => $groupId, 99 | 'schema' => $schema, 100 | 'normalized_query' => $normalizedQuery, 101 | 'real_query' => $realQuery, 102 | 'impact_description' => $impactDescription, 103 | ]); 104 | } 105 | 106 | public function setRealQuery(int $queryId, string $sql): void { 107 | $this->connection->update('query', [ 108 | 'real_query' => $sql, 109 | ])->where('id=%i', $queryId) 110 | ->execute(); 111 | } 112 | 113 | public function updateConversation( 114 | int $queryId, 115 | LLMConversation $conversation, 116 | string $conversationMarkdown 117 | ): void { 118 | $data = []; 119 | 120 | $data['llm_conversation'] = json_encode($conversation->jsonSerialize(), JSON_THROW_ON_ERROR); 121 | $data['llm_conversation_markdown'] = $conversationMarkdown; 122 | $this->connection->update('query', $data)->where('id=%i', $queryId)->execute(); 123 | } 124 | 125 | public function getRuns(): array { 126 | return $this->connection->query('SELECT * FROM run')->fetchAll(); 127 | } 128 | 129 | public function getGroupsByRunId(int $runId): array { 130 | return $this->connection->query('SELECT * FROM [group] WHERE run_id = %i', $runId)->fetchAll(); 131 | } 132 | 133 | public function getQueriesByRunId(int $runId): array { 134 | return $this->connection->query('SELECT * FROM query WHERE run_id = %i', $runId)->fetchAll(); 135 | } 136 | 137 | public function getRun(int $id): ?array { 138 | $result = $this->connection->query('SELECT * FROM run WHERE id = %i', $id)->fetch(); 139 | if ($result) { 140 | return (array)$result; 141 | } 142 | 143 | return null; 144 | } 145 | 146 | public function getQuery(int $id): ?array { 147 | $result = $this->connection->query('SELECT * FROM query WHERE id = %i', $id)->fetch(); 148 | if ($result) { 149 | return (array)$result; 150 | } 151 | 152 | return null; 153 | } 154 | 155 | public function getGroup(int $id): ?array { 156 | $result = $this->connection->query('SELECT * FROM [group] WHERE id = %i', $id)->fetch(); 157 | if ($result) { 158 | return (array)$result; 159 | } 160 | 161 | return null; 162 | } 163 | 164 | public function getQueriesWithoutRealQuery(int $runId): array { 165 | return $this->connection->query('SELECT id, digest, schema FROM query WHERE run_id = %i AND real_query IS NULL', $runId)->fetchAll(); 166 | } 167 | 168 | public function getQueriesCount(int $runId): int { 169 | return $this->connection->query('SELECT COUNT(*) FROM query WHERE run_id = %i', $runId)->fetchSingle(); 170 | } 171 | 172 | public function deleteRun(int $runId): void { 173 | $this->connection->query('DELETE FROM query WHERE run_id = %i', $runId); 174 | $this->connection->query('DELETE FROM `group` WHERE run_id = %i', $runId); 175 | $this->connection->query('DELETE FROM run WHERE id = %i', $runId); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/Tool/PerformanceSchemaQueryTool.php: -------------------------------------------------------------------------------- 1 | 'object', 27 | 'required' => ['query'], 28 | 'properties' => [ 29 | 'query' => [ 30 | 'type' => 'string', 31 | 'description' => 'SQL query to run against performance_schema', 32 | ], 33 | ], 34 | ]; 35 | } 36 | 37 | public function handle(array $input): ToolResponse { 38 | return new ToolResponse($this->queryExecutor->executeQuery('performance_schema', $input['query'], $this->cacheDatabaseResults, 250)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Tool/QueryTool.php: -------------------------------------------------------------------------------- 1 | 'object', 27 | 'required' => ['database', 'query'], 28 | 'properties' => [ 29 | 'database' => [ 30 | 'type' => 'string', 31 | 'description' => 'Database name', 32 | ], 33 | 'query' => [ 34 | 'type' => 'string', 35 | 'description' => 'SQL query to run against database', 36 | ], 37 | ], 38 | ]; 39 | } 40 | 41 | public function handle(array $input): ToolResponse { 42 | return new ToolResponse($this->queryExecutor->executeQuery($input['database'], $input['query'], $this->cacheDatabaseResults, 250)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/schema/state_database.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS run ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | hostname TEXT NOT NULL, 4 | use_real_query INTEGER NOT NULL DEFAULT 0, 5 | use_database_access INTEGER NOT NULL DEFAULT 0, 6 | date_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 7 | llm_conversation TEXT, 8 | llm_conversation_markdown TEXT, 9 | input TEXT, 10 | output TEXT NOT NULL 11 | ); 12 | 13 | CREATE TABLE IF NOT EXISTS `group` ( 14 | id INTEGER PRIMARY KEY AUTOINCREMENT, 15 | date_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 16 | run_id INTEGER NOT NULL, 17 | name TEXT NOT NULL, 18 | description TEXT, 19 | FOREIGN KEY (run_id) REFERENCES run(id) ON DELETE CASCADE 20 | ); 21 | 22 | CREATE TABLE IF NOT EXISTS query ( 23 | id INTEGER PRIMARY KEY AUTOINCREMENT, 24 | digest TEXT NOT NULL, 25 | run_id INTEGER NOT NULL, 26 | group_id INTEGER NOT NULL, 27 | schema TEXT NOT NULL, 28 | normalized_query TEXT, 29 | real_query TEXT, 30 | impact_description TEXT, 31 | llm_conversation TEXT, 32 | llm_conversation_markdown TEXT, 33 | FOREIGN KEY (group_id) REFERENCES `group`(id) ON DELETE CASCADE 34 | ); -------------------------------------------------------------------------------- /templates/analysis.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}Query Analysis - SQL Optimizer{% endblock %} 4 | 5 | {% block content %} 6 | {% if export is not defined %} 7 | 8 |17 | {% else %} 18 |9 | 10 | Back to Query List 11 | 12 | 13 | Export as HTML 14 | 15 |16 |19 |23 | {% endif %} 24 | 25 |20 |22 |Exported on {{ "now"|date('Y-m-d H:i:s') }}
21 |26 |56 | 57 | {% for message in messages %} 58 |27 |29 |Query Analysis
28 |30 |55 |31 |40 | 41 |32 |35 |Schema
33 |{{ query.schema }}
34 |36 |39 |Group
37 |{{ group.name }}
38 |Description
42 |{{ query.impact_description }}
43 | 44 |SQL Query
45 |{{ sql|raw }}46 | 47 | {% if query.real_query is empty %} 48 |49 | 50 | Could not retrieve sample of real query and analysis is missing EXPLAIN result. 51 |52 | {% endif %} 53 | 54 |59 | {% if message.role == 'user' %} 60 |74 | {% endfor %} 75 | 76 | {% if export is not defined %} 77 |61 |63 | {% else %} 64 |User comment
62 |65 |67 | {% endif %} 68 |Optimization Recommendations
66 |69 |73 |70 | {{ message.content|raw }} 71 |72 |78 |99 | {% endif %} 100 | {% endblock %} 101 | 102 | {% block stylesheets %} 103 | 104 | 118 | {% endblock %} 119 | 120 | {% block javascripts %} 121 | {% if export is not defined %} 122 | 123 | 124 | 125 | 181 | {% else %} 182 | 183 | 184 | 185 | 192 | {% endif %} 193 | {% endblock %} -------------------------------------------------------------------------------- /templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |79 |81 |Send additional info
80 |82 |98 |83 | 96 |97 |{% block title %}SQL Optimizer{% endblock %} 7 | 8 | 9 | 10 | 51 | {% block stylesheets %}{% endblock %} 52 | 53 | 54 |55 |59 | 60 | 65 | 66 |56 | Loading... 57 |58 |67 | {% block content %}{% endblock %} 68 | 69 | 70 | 75 | 76 | 77 | 80 | {% block javascripts %}{% endblock %} 81 | 82 | -------------------------------------------------------------------------------- /templates/run_detail.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block title %}SQL AI Optimizer - Run #{{ run.id }}{% endblock %} 4 | 5 | {% block content %} 6 | {% if export is not defined %} 7 |8 |24 | {% else %} 25 |Run #{{ run.id }}
9 |10 |23 |11 | 14 |19 | 18 | 20 | Back to All Runs 21 | 22 |26 |29 | {% endif %} 30 | 31 | {{ summary|raw }} 32 | 33 | {% if export is not defined %} 34 |SQL AI Optimizer - Run #{{ run.id }}
27 |Exported on {{ "now"|date('Y-m-d H:i:s') }}
28 |