Files
rspade_system/app/RSpade/Commands/Database/Check_Indexes_Command.php
root 29c657f7a7 Exclude tests directory from framework publish
Add 100+ automated unit tests from .expect file specifications
Add session system test
Add rsx:constants:regenerate command test
Add rsx:logrotate command test
Add rsx:clean command test
Add rsx:manifest:stats command test
Add model enum system test
Add model mass assignment prevention test
Add rsx:check command test
Add migrate:status command test

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-25 03:59:58 +00:00

884 lines
29 KiB
PHP

<?php
namespace App\RSpade\Commands\Database;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\RSpade\Core\Manifest\Manifest;
use PhpParser\ParserFactory;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\Node;
/**
* Database Index Analysis and Recommendation System
*
* Analyzes RSX application code to detect required database indexes from:
* 1. Model relationships (foreign key columns from #[Relationship] methods)
* 2. Query patterns (AST analysis of Model::where() chains in /rsx/)
*
* Generates optimized index recommendations using covering index logic and
* outputs a migration file for review and application.
*/
class Check_Indexes_Command extends Command
{
protected $signature = 'rsx:db:check_indexes
{--generate-migration : Create migration file with recommendations}
{--table= : Analyze only specific table}
{--format=text : Output format (text|json)}
{--no-relationships : Skip relationship analysis}
{--no-queries : Skip query pattern analysis}
{--show-all : Show all recommendations even if quota exceeded}
{--show-details : Show detailed analysis process}';
protected $description = 'Analyze database index requirements from relationships and query patterns';
/**
* All collected index requirements before optimization
*
* Structure:
* [
* 'table_name' => [
* ['columns' => ['col1', 'col2'], 'source' => 'file:line', 'priority' => 'relationship|query', 'type' => 'hasMany|where'],
* ...
* ]
* ]
*/
protected $requirements = [];
/**
* Optimized index recommendations after deduplication
*
* Structure: same as $requirements but deduplicated
*/
protected $recommendations = [];
/**
* Existing indexes per table
*
* Structure:
* [
* 'table_name' => [
* ['name' => 'idx_name', 'columns' => ['col1', 'col2'], 'unique' => false, 'auto_generated' => true],
* ...
* ]
* ]
*/
protected $existing_indexes = [];
/**
* Tables that have site_id column (siteable models)
*/
protected $siteable_tables = [];
/**
* Maximum indexes allowed per table (MySQL InnoDB limit)
*/
const MAX_INDEXES_PER_TABLE = 64;
/**
* Available quota for auto-generated indexes per table
*/
const AUTO_INDEX_QUOTA = 32;
public function handle()
{
$this->info('Database Index Analysis');
$this->info(str_repeat('=', 50));
$this->line('');
// Phase 1: Collect index requirements
$this->_collect_index_requirements();
// Phase 2: Optimize requirements using covering index logic
$this->_optimize_requirements();
// Phase 3: Generate index diff (creates vs drops)
$diff = $this->_generate_index_diff();
// Phase 4: Output summary
$this->_output_summary($diff);
// Phase 5: Generate migration if requested
if ($this->option('generate-migration')) {
$this->_generate_migration($diff);
}
return 0;
}
/**
* Phase 1: Collect all index requirements from relationships and query patterns
*/
protected function _collect_index_requirements(): void
{
if (!$this->option('no-relationships')) {
$this->_detect_relationship_requirements();
}
if (!$this->option('no-queries')) {
$this->_detect_query_pattern_requirements();
}
if ($this->option('show-details')) {
$this->line('Total requirements collected: ' . $this->_count_requirements());
}
}
/**
* Detect index requirements from model relationships (#[Relationship] methods)
* Priority: HIGHEST - Always included, never cut
*/
protected function _detect_relationship_requirements(): void
{
if ($this->option('show-details')) {
$this->info('Analyzing model relationships...');
}
$model_metadata = Manifest::php_get_extending('Rsx_Model_Abstract');
$relationship_count = 0;
foreach ($model_metadata as $metadata) {
$model_fqcn = $metadata['fqcn'];
if (!isset($metadata['public_instance_methods'])) {
continue;
}
foreach ($metadata['public_instance_methods'] as $method_name => $method_data) {
// Only process methods with #[Relationship] attribute
if (!isset($method_data['attributes']['Relationship'])) {
continue;
}
// Instantiate the relationship to introspect it
try {
$model_instance = new $model_fqcn();
$relation = $model_instance->$method_name();
if (!$relation instanceof \Illuminate\Database\Eloquent\Relations\Relation) {
continue;
}
$this->_process_relationship($model_fqcn, $method_name, $relation, $metadata['file']);
$relationship_count++;
} catch (\Throwable $e) {
// Skip relationships that can't be introspected
continue;
}
}
}
if ($this->option('show-details')) {
$this->line("Found {$relationship_count} relationships");
}
}
/**
* Process a single relationship and add index requirements
*/
protected function _process_relationship(
string $source_model_fqcn,
string $method_name,
\Illuminate\Database\Eloquent\Relations\Relation $relation,
string $source_file
): void {
$relationship_type = class_basename(get_class($relation));
// BelongsTo and MorphTo use foreign key on source table (already have it)
if ($relation instanceof \Illuminate\Database\Eloquent\Relations\BelongsTo ||
$relation instanceof \Illuminate\Database\Eloquent\Relations\MorphTo) {
return;
}
// For HasMany, HasOne, MorphMany, MorphOne - check related table needs index
$target_table = null;
$target_columns = [];
if ($relation instanceof \Illuminate\Database\Eloquent\Relations\HasMany ||
$relation instanceof \Illuminate\Database\Eloquent\Relations\HasOne) {
$target_table = $relation->getRelated()->getTable();
$target_columns = [$relation->getForeignKeyName()];
}
elseif ($relation instanceof \Illuminate\Database\Eloquent\Relations\MorphMany ||
$relation instanceof \Illuminate\Database\Eloquent\Relations\MorphOne) {
$target_table = $relation->getRelated()->getTable();
$morph_name = $relation->getMorphType();
$morph_name = str_replace('_type', '', $morph_name);
$target_columns = [$morph_name . '_id'];
}
elseif ($relation instanceof \Illuminate\Database\Eloquent\Relations\BelongsToMany) {
// Pivot table needs indexes on both foreign keys
$this->_process_belongs_to_many($source_model_fqcn, $method_name, $relation, $source_file);
return;
}
if (!$target_table || empty($target_columns)) {
return;
}
$this->_add_requirement($target_table, $target_columns, $source_file, 'relationship', $relationship_type);
}
/**
* Process BelongsToMany relationships (pivot tables)
*/
protected function _process_belongs_to_many(
string $source_model_fqcn,
string $method_name,
\Illuminate\Database\Eloquent\Relations\BelongsToMany $relation,
string $source_file
): void {
$pivot_table = $relation->getTable();
$foreign_pivot_key = $relation->getForeignPivotKeyName();
$related_pivot_key = $relation->getRelatedPivotKeyName();
$this->_add_requirement($pivot_table, [$foreign_pivot_key], $source_file, 'relationship', 'BelongsToMany');
$this->_add_requirement($pivot_table, [$related_pivot_key], $source_file, 'relationship', 'BelongsToMany');
}
/**
* Detect index requirements from query patterns (Model::where() chains)
* Priority: MEDIUM - Subject to quota limits
*/
protected function _detect_query_pattern_requirements(): void
{
if ($this->option('show-details')) {
$this->info('Analyzing query patterns...');
}
// Get all PHP files from manifest that:
// 1. Define a class
// 2. Are in the manifest
// 3. Have base path starting with ./rsx
$all_files = Manifest::get_all();
$php_files_to_scan = [];
foreach ($all_files as $file => $metadata) {
// Must be PHP file
if (!isset($metadata['extension']) || $metadata['extension'] !== 'php') {
continue;
}
// Must define a class
if (!isset($metadata['class'])) {
continue;
}
// Must be in ./rsx
if (!str_starts_with($file, 'rsx/')) {
continue;
}
$php_files_to_scan[] = [
'file' => $file,
'class' => $metadata['class'],
];
}
if ($this->option('show-details')) {
$this->line('Scanning ' . count($php_files_to_scan) . ' PHP files for query patterns');
}
// Parse each file with AST to find Model::where() chains
$parser = (new ParserFactory())->createForHostVersion();
$patterns_found = 0;
foreach ($php_files_to_scan as $file_info) {
$file_path = base_path($file_info['file']);
$code = file_get_contents($file_path);
try {
$ast = $parser->parse($code);
// Traverse AST to find where() chains
$visitor = new class($this, $file_info['file']) extends NodeVisitorAbstract {
private $command;
private $source_file;
public function __construct($command, $source_file)
{
$this->command = $command;
$this->source_file = $source_file;
}
public function enterNode(Node $node)
{
// Look for method calls
if (!$node instanceof Node\Expr\MethodCall &&
!$node instanceof Node\Expr\StaticCall) {
return null;
}
// Extract where() chain
$chain = $this->extractWhereChain($node);
if (!empty($chain['columns']) && $chain['model_class']) {
$this->command->_process_query_pattern(
$chain['model_class'],
$chain['columns'],
$this->source_file . ':' . $node->getStartLine()
);
}
return null;
}
private function extractWhereChain($node): array
{
$columns = [];
$model_class = null;
$current = $node;
// Walk backwards through method chain
while ($current) {
// Check if this is a where() call
if ($current instanceof Node\Expr\MethodCall ||
$current instanceof Node\Expr\StaticCall) {
$method_name = $current->name instanceof Node\Identifier
? $current->name->toString()
: null;
// If it's where(), extract column name
if ($method_name === 'where' && isset($current->args[0])) {
$first_arg = $current->args[0]->value;
// Only process string literals (not variables)
if ($first_arg instanceof Node\Scalar\String_) {
// Add to beginning since we're walking backwards
array_unshift($columns, $first_arg->value);
} else {
// Variable column name - stop processing this chain
return ['columns' => [], 'model_class' => null];
}
}
// Check if we've reached the Model class static call
if ($current instanceof Node\Expr\StaticCall) {
$class = $current->class;
if ($class instanceof Node\Name) {
$class_name = $class->toString();
// Check if it ends with _Model
if (str_ends_with($class_name, '_Model')) {
$model_class = $class_name;
}
}
}
// Move to next in chain
if ($current instanceof Node\Expr\MethodCall) {
$current = $current->var;
} elseif ($current instanceof Node\Expr\StaticCall) {
// Reached the static call, stop
break;
}
} else {
break;
}
}
// Only return if we have at least one column and found a model
if (empty($columns) || !$model_class) {
return ['columns' => [], 'model_class' => null];
}
return [
'columns' => $columns,
'model_class' => $model_class,
];
}
};
$traverser = new NodeTraverser();
$traverser->addVisitor($visitor);
$traverser->traverse($ast);
} catch (\Throwable $e) {
// Skip files that fail to parse
continue;
}
}
if ($this->option('show-details')) {
$this->line('Query pattern analysis complete');
}
}
/**
* Process a detected query pattern and add index requirement
*/
public function _process_query_pattern(string $model_class, array $columns, string $source): void
{
// Resolve model class to table name
try {
$table = $model_class::get_table_static();
} catch (\Throwable $e) {
// Model class doesn't exist or can't get table
return;
}
$this->_add_requirement($table, $columns, $source, 'query', 'where');
}
/**
* Add an index requirement to the collection
*/
protected function _add_requirement(
string $table,
array $columns,
string $source,
string $priority,
string $type
): void {
// Filter out empty columns
$columns = array_filter($columns);
if (empty($columns)) {
return;
}
if (!isset($this->requirements[$table])) {
$this->requirements[$table] = [];
}
$this->requirements[$table][] = [
'columns' => array_values($columns),
'source' => $source,
'priority' => $priority,
'type' => $type,
];
}
/**
* Phase 2: Optimize requirements using covering index logic
*/
protected function _optimize_requirements(): void
{
if ($this->option('show-details')) {
$this->info('Optimizing index requirements...');
}
foreach ($this->requirements as $table => $requirements) {
$optimized = $this->_apply_covering_index_logic($requirements);
$optimized = $this->_apply_quota_limits($table, $optimized);
$this->recommendations[$table] = $optimized;
}
}
/**
* Apply covering index deduplication logic
*
* Algorithm:
* - Index (a,b,c) can satisfy queries on (a) or (a,b) but NOT (b) or (c) alone
* - When single-column requirement conflicts with multi-column requirement,
* put single-column FIRST to enable left-prefix usage
* - Example: Requirements (status) + (user_id, status) → Create (status, user_id)
*/
protected function _apply_covering_index_logic(array $requirements): array
{
// Group by column signature
$groups = [];
foreach ($requirements as $req) {
$sig = implode(',', $req['columns']);
if (!isset($groups[$sig])) {
$groups[$sig] = [];
}
$groups[$sig][] = $req;
}
// Try to find covering relationships and optimize
$optimized = [];
$covered = [];
// Sort by column count (descending) to check longer indexes first
uasort($groups, function($a, $b) {
return count($b[0]['columns']) - count($a[0]['columns']);
});
foreach ($groups as $sig => $reqs) {
if (in_array($sig, $covered)) {
continue;
}
$cols = $reqs[0]['columns'];
// Check if this is covered by any existing optimized index
$is_covered = false;
foreach ($optimized as $opt) {
if ($this->_is_covered_by($cols, $opt['columns'])) {
$is_covered = true;
break;
}
}
if (!$is_covered) {
$optimized[] = $reqs[0]; // Keep first occurrence
} else {
$covered[] = $sig;
}
}
return $optimized;
}
/**
* Check if needed columns are covered by existing index via left-prefix matching
*
* Example: (a,b) is covered by (a,b,c)
* Example: (b) is NOT covered by (a,b)
*/
protected function _is_covered_by(array $needed, array $existing): bool
{
if (count($needed) > count($existing)) {
return false;
}
for ($i = 0; $i < count($needed); $i++) {
if ($needed[$i] !== $existing[$i]) {
return false;
}
}
return true;
}
/**
* Apply quota limits and prioritization
*
* Priority tiers:
* 1. Relationship indexes (foreign keys) - Never cut
* 2. Multi-column query indexes (2+ columns) - Cut last
* 3. Single-column query indexes - Cut first
*/
protected function _apply_quota_limits(string $table, array $recommendations): array
{
// Get existing user-defined indexes (non-auto-generated)
$existing = $this->_get_table_indexes($table);
$user_indexes = array_filter($existing, function($idx) {
return !$this->_is_auto_generated_index($idx['name']);
});
$available_quota = min(
self::AUTO_INDEX_QUOTA,
self::MAX_INDEXES_PER_TABLE - count($user_indexes)
);
if (count($recommendations) <= $available_quota) {
return $recommendations;
}
// Need to cut some indexes - prioritize by tier
$relationship_indexes = array_filter($recommendations, function($r) {
return $r['priority'] === 'relationship';
});
$multi_col_query_indexes = array_filter($recommendations, function($r) {
return $r['priority'] === 'query' && count($r['columns']) >= 2;
});
$single_col_query_indexes = array_filter($recommendations, function($r) {
return $r['priority'] === 'query' && count($r['columns']) === 1;
});
// Always include all relationship indexes
$result = array_values($relationship_indexes);
$remaining = $available_quota - count($result);
// Add as many multi-column indexes as fit
foreach ($multi_col_query_indexes as $idx) {
if ($remaining > 0) {
$result[] = $idx;
$remaining--;
}
}
// Add single-column indexes if space remains
foreach ($single_col_query_indexes as $idx) {
if ($remaining > 0) {
$result[] = $idx;
$remaining--;
}
}
return $result;
}
/**
* Phase 3: Generate index diff (creates and drops)
*/
protected function _generate_index_diff(): array
{
$diff = [
'creates' => [],
'drops' => [],
'warnings' => [],
];
foreach ($this->recommendations as $table => $recommendations) {
$existing = $this->_get_table_indexes($table);
// Find indexes to drop (auto-generated but not in recommendations)
foreach ($existing as $idx) {
if ($this->_is_auto_generated_index($idx['name'])) {
$found = false;
foreach ($recommendations as $rec) {
if ($this->_indexes_match($idx['columns'], $rec['columns'])) {
$found = true;
break;
}
}
if (!$found) {
$diff['drops'][$table][] = $idx;
}
}
}
// Find indexes to create (recommended but not existing)
foreach ($recommendations as $rec) {
$found = false;
foreach ($existing as $idx) {
if ($this->_indexes_match($idx['columns'], $rec['columns'])) {
$found = true;
break;
}
}
if (!$found) {
$diff['creates'][$table][] = $rec;
}
}
}
return $diff;
}
/**
* Check if an index name is auto-generated
*/
protected function _is_auto_generated_index(string $name): bool
{
return strpos($name, 'idx_auto_') === 0;
}
/**
* Check if two index column arrays match
*/
protected function _indexes_match(array $cols1, array $cols2): bool
{
return $cols1 === $cols2;
}
/**
* Phase 4: Output summary
*/
protected function _output_summary(array $diff): void
{
$total_creates = 0;
$total_drops = 0;
foreach ($diff['creates'] as $table => $creates) {
$total_creates += count($creates);
}
foreach ($diff['drops'] as $table => $drops) {
$total_drops += count($drops);
}
if ($total_creates === 0 && $total_drops === 0) {
$this->info('✅ All indexes are up to date');
$this->line('');
$this->_output_limitations_warning();
return;
}
$this->info("Found {$total_creates} indexes to create and {$total_drops} to drop");
$this->line('');
// Show details per table
foreach ($diff['creates'] as $table => $creates) {
$this->line("Table: {$table}");
foreach ($creates as $create) {
$cols = implode(', ', $create['columns']);
$this->line(" + Create index on ({$cols}) - {$create['source']}");
}
}
foreach ($diff['drops'] as $table => $drops) {
$this->line("Table: {$table}");
foreach ($drops as $drop) {
$cols = implode(', ', $drop['columns']);
$this->line(" - Drop index {$drop['name']} on ({$cols})");
}
}
$this->line('');
if (!$this->option('generate-migration')) {
$this->line('To generate migration:');
$this->line(' php artisan rsx:db:check_indexes --generate-migration');
}
$this->line('');
$this->_output_limitations_warning();
}
/**
* Output limitations warning (always shown)
*/
protected function _output_limitations_warning(): void
{
$this->warn('⚠️ LIMITATIONS');
$this->line('');
$this->line('This analysis cannot detect:');
$this->line(' ✗ Dynamic column names: where($variable, ...)');
$this->line(' ✗ Join conditions (separate relationship analysis)');
$this->line(' ✗ Complex WHERE clauses (whereRaw, whereIn, etc.)');
$this->line(' ✗ Runtime-determined query patterns');
$this->line('');
$this->line('Always benchmark critical queries and add custom indexes as needed.');
$this->line('Use EXPLAIN to verify query performance in production.');
}
/**
* Phase 5: Generate migration file
*/
protected function _generate_migration(array $diff): void
{
$timestamp = date('Y_m_d_His');
$filename = database_path("migrations/{$timestamp}_auto_index_recommendations.php");
$content = $this->_build_migration_content($diff);
file_put_contents($filename, $content);
$this->info("Migration generated: {$filename}");
$this->line('');
$this->line('Review the migration and run:');
$this->line(' php artisan migrate');
}
/**
* Build migration file content
*/
protected function _build_migration_content(array $diff): string
{
$statements = [];
// Group by table and generate statements
foreach ($diff['drops'] as $table => $drops) {
$statements[] = " // Table: {$table}";
$statements[] = " // ============================================";
$statements[] = "";
$statements[] = " // DROP: Obsolete auto-generated indexes";
$statements[] = "";
foreach ($drops as $drop) {
$statements[] = " DB::statement('DROP INDEX IF EXISTS {$drop['name']} ON {$table}');";
}
$statements[] = "";
}
foreach ($diff['creates'] as $table => $creates) {
if (!isset($diff['drops'][$table])) {
$statements[] = " // Table: {$table}";
$statements[] = " // ============================================";
$statements[] = "";
}
$statements[] = " // CREATE: New recommended indexes";
$statements[] = "";
foreach ($creates as $create) {
$index_name = 'idx_auto_' . implode('_', $create['columns']);
$cols_str = implode(', ', $create['columns']);
$statements[] = " // Source: {$create['source']}";
$statements[] = " // Priority: {$create['priority']} ({$create['type']})";
$statements[] = " DB::statement('";
$statements[] = " CREATE INDEX {$index_name}";
$statements[] = " ON {$table} ({$cols_str})";
$statements[] = " ');";
$statements[] = "";
}
}
$content = implode("\n", $statements);
return <<<PHP
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up()
{
{$content}
}
};
PHP;
}
/**
* Get existing indexes for a table
*/
protected function _get_table_indexes(string $table): array
{
// Check if we already cached this table's indexes
if (isset($this->existing_indexes[$table])) {
return $this->existing_indexes[$table];
}
try {
$results = DB::select("SHOW INDEX FROM `{$table}`");
} catch (\Exception $e) {
// Table doesn't exist
return [];
}
$indexes = [];
foreach ($results as $row) {
$index_name = $row->Key_name;
if (!isset($indexes[$index_name])) {
$indexes[$index_name] = [
'name' => $index_name,
'unique' => $row->Non_unique == 0,
'columns' => [],
];
}
// Add column in correct order
$indexes[$index_name]['columns'][$row->Seq_in_index - 1] = $row->Column_name;
}
// Sort columns by their sequence
foreach ($indexes as &$index) {
ksort($index['columns']);
$index['columns'] = array_values($index['columns']);
}
$this->existing_indexes[$table] = array_values($indexes);
return $this->existing_indexes[$table];
}
/**
* Count total requirements collected
*/
protected function _count_requirements(): int
{
$count = 0;
foreach ($this->requirements as $table => $reqs) {
$count += count($reqs);
}
return $count;
}
}