Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
342
src/Framework/Database/Schema/Blueprint.php
Normal file
342
src/Framework/Database/Schema/Blueprint.php
Normal file
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
use App\Framework\Database\Schema\Commands\{
|
||||
DropColumnCommand,
|
||||
DropForeignCommand,
|
||||
DropIndexCommand,
|
||||
RawCommand,
|
||||
RenameColumnCommand
|
||||
};
|
||||
|
||||
/**
|
||||
* Fluent table builder for defining table structures
|
||||
*/
|
||||
final class Blueprint
|
||||
{
|
||||
public readonly string $table;
|
||||
|
||||
public array $columns = [];
|
||||
|
||||
public array $indexes = [];
|
||||
|
||||
public array $foreignKeys = [];
|
||||
|
||||
public array $commands = [];
|
||||
|
||||
public ?string $engine = null;
|
||||
|
||||
public ?string $charset = null;
|
||||
|
||||
public ?string $collation = null;
|
||||
|
||||
public bool $temporary = false;
|
||||
|
||||
public function __construct(string $table)
|
||||
{
|
||||
$this->table = $table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table modifiers
|
||||
*/
|
||||
public function engine(string $engine): self
|
||||
{
|
||||
$this->engine = $engine;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function charset(string $charset): self
|
||||
{
|
||||
$this->charset = $charset;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function collation(string $collation): self
|
||||
{
|
||||
$this->collation = $collation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function temporary(): self
|
||||
{
|
||||
$this->temporary = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column definitions
|
||||
*/
|
||||
public function id(string $column = 'id'): ColumnDefinition
|
||||
{
|
||||
return $this->bigIncrements($column);
|
||||
}
|
||||
|
||||
public function ulid(string $column = 'ulid'): ColumnDefinition
|
||||
{
|
||||
return $this->string($column, 26)->unique();
|
||||
}
|
||||
|
||||
public function uuid(string $column = 'uuid'): ColumnDefinition
|
||||
{
|
||||
return $this->string($column, 36)->unique();
|
||||
}
|
||||
|
||||
public function increments(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('increments', $column);
|
||||
}
|
||||
|
||||
public function bigIncrements(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('bigIncrements', $column);
|
||||
}
|
||||
|
||||
public function integer(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('integer', $column);
|
||||
}
|
||||
|
||||
public function bigInteger(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('bigInteger', $column);
|
||||
}
|
||||
|
||||
public function unsignedInteger(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->integer($column)->unsigned();
|
||||
}
|
||||
|
||||
public function unsignedBigInteger(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->bigInteger($column)->unsigned();
|
||||
}
|
||||
|
||||
public function tinyInteger(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('tinyInteger', $column);
|
||||
}
|
||||
|
||||
public function smallInteger(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('smallInteger', $column);
|
||||
}
|
||||
|
||||
public function mediumInteger(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('mediumInteger', $column);
|
||||
}
|
||||
|
||||
public function float(string $column, int $precision = 8, int $scale = 2): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('float', $column, compact('precision', 'scale'));
|
||||
}
|
||||
|
||||
public function double(string $column, ?int $precision = null, ?int $scale = null): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('double', $column, compact('precision', 'scale'));
|
||||
}
|
||||
|
||||
public function decimal(string $column, int $precision = 8, int $scale = 2): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('decimal', $column, compact('precision', 'scale'));
|
||||
}
|
||||
|
||||
public function boolean(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('boolean', $column);
|
||||
}
|
||||
|
||||
public function string(string $column, int $length = 255): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('string', $column, compact('length'));
|
||||
}
|
||||
|
||||
public function text(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('text', $column);
|
||||
}
|
||||
|
||||
public function mediumText(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('mediumText', $column);
|
||||
}
|
||||
|
||||
public function longText(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('longText', $column);
|
||||
}
|
||||
|
||||
public function binary(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('binary', $column);
|
||||
}
|
||||
|
||||
public function json(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('json', $column);
|
||||
}
|
||||
|
||||
public function jsonb(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('jsonb', $column);
|
||||
}
|
||||
|
||||
public function date(string $column): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('date', $column);
|
||||
}
|
||||
|
||||
public function dateTime(string $column, int $precision = 0): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('dateTime', $column, compact('precision'));
|
||||
}
|
||||
|
||||
public function time(string $column, int $precision = 0): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('time', $column, compact('precision'));
|
||||
}
|
||||
|
||||
public function timestamp(string $column, int $precision = 0): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('timestamp', $column, compact('precision'));
|
||||
}
|
||||
|
||||
public function timestamps(int $precision = 0): void
|
||||
{
|
||||
$this->timestamp('created_at', $precision)->nullable();
|
||||
$this->timestamp('updated_at', $precision)->nullable();
|
||||
}
|
||||
|
||||
public function softDeletes(string $column = 'deleted_at', int $precision = 0): ColumnDefinition
|
||||
{
|
||||
return $this->timestamp($column, $precision)->nullable();
|
||||
}
|
||||
|
||||
public function enum(string $column, array $allowed): ColumnDefinition
|
||||
{
|
||||
return $this->addColumn('enum', $column, compact('allowed'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Index definitions
|
||||
*/
|
||||
public function primary(string ...$columns): self
|
||||
{
|
||||
$this->indexes[] = new IndexDefinition('primary', (array) $columns, IndexType::PRIMARY);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function unique(string|array $columns, ?string $name = null): self
|
||||
{
|
||||
$this->indexes[] = new IndexDefinition($name, (array) $columns, IndexType::UNIQUE);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function index(string|array $columns, ?string $name = null): self
|
||||
{
|
||||
$this->indexes[] = new IndexDefinition($name, (array) $columns, IndexType::INDEX);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function fulltext(string|array $columns, ?string $name = null): self
|
||||
{
|
||||
$this->indexes[] = new IndexDefinition($name, (array) $columns, IndexType::FULLTEXT);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function spatialIndex(string|array $columns, ?string $name = null): self
|
||||
{
|
||||
$this->indexes[] = new IndexDefinition($name, (array) $columns, IndexType::SPATIAL);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreign key definitions
|
||||
*/
|
||||
public function foreign(string|array $columns): ForeignKeyDefinition
|
||||
{
|
||||
$foreign = new ForeignKeyDefinition((array) $columns);
|
||||
$this->foreignKeys[] = $foreign;
|
||||
|
||||
return $foreign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column operations
|
||||
*/
|
||||
public function dropColumn(string|array $columns): self
|
||||
{
|
||||
$this->commands[] = new DropColumnCommand((array) $columns);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function renameColumn(string $from, string $to): self
|
||||
{
|
||||
$this->commands[] = new RenameColumnCommand($from, $to);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index operations
|
||||
*/
|
||||
public function dropIndex(string|array $index): self
|
||||
{
|
||||
$this->commands[] = new DropIndexCommand($index);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function dropUnique(string|array $index): self
|
||||
{
|
||||
$this->commands[] = new DropIndexCommand($index);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function dropPrimary(string $index = 'primary'): self
|
||||
{
|
||||
$this->commands[] = new DropIndexCommand($index);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function dropForeign(string|array $index): self
|
||||
{
|
||||
$this->commands[] = new DropForeignCommand($index);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw SQL
|
||||
*/
|
||||
public function raw(string $sql): self
|
||||
{
|
||||
$this->commands[] = new RawCommand($sql);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function addColumn(string $type, string $name, array $parameters = []): ColumnDefinition
|
||||
{
|
||||
$column = new ColumnDefinition($type, $name, $parameters);
|
||||
$this->columns[] = $column;
|
||||
|
||||
return $column;
|
||||
}
|
||||
}
|
||||
143
src/Framework/Database/Schema/ColumnDefinition.php
Normal file
143
src/Framework/Database/Schema/ColumnDefinition.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
/**
|
||||
* Column definition with fluent modifiers
|
||||
*/
|
||||
final class ColumnDefinition
|
||||
{
|
||||
public readonly string $type;
|
||||
|
||||
public readonly string $name;
|
||||
|
||||
public readonly array $parameters;
|
||||
|
||||
public bool $nullable = false;
|
||||
|
||||
public mixed $default = null;
|
||||
|
||||
public bool $hasDefault = false;
|
||||
|
||||
public bool $autoIncrement = false;
|
||||
|
||||
public bool $unsigned = false;
|
||||
|
||||
public bool $unique = false;
|
||||
|
||||
public bool $primary = false;
|
||||
|
||||
public bool $index = false;
|
||||
|
||||
public ?string $comment = null;
|
||||
|
||||
public ?string $after = null;
|
||||
|
||||
public bool $first = false;
|
||||
|
||||
public ?string $charset = null;
|
||||
|
||||
public ?string $collation = null;
|
||||
|
||||
public function __construct(string $type, string $name, array $parameters = [])
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->name = $name;
|
||||
$this->parameters = $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fluent modifiers
|
||||
*/
|
||||
public function nullable(bool $nullable = true): self
|
||||
{
|
||||
$this->nullable = $nullable;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function default(mixed $value): self
|
||||
{
|
||||
$this->default = $value;
|
||||
$this->hasDefault = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function useCurrent(): self
|
||||
{
|
||||
return $this->default('CURRENT_TIMESTAMP');
|
||||
}
|
||||
|
||||
public function autoIncrement(): self
|
||||
{
|
||||
$this->autoIncrement = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function unsigned(): self
|
||||
{
|
||||
$this->unsigned = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function unique(): self
|
||||
{
|
||||
$this->unique = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function primary(): self
|
||||
{
|
||||
$this->primary = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function index(): self
|
||||
{
|
||||
$this->index = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function comment(string $comment): self
|
||||
{
|
||||
$this->comment = $comment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function after(string $column): self
|
||||
{
|
||||
$this->after = $column;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function first(): self
|
||||
{
|
||||
$this->first = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function charset(string $charset): self
|
||||
{
|
||||
$this->charset = $charset;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function collation(string $collation): self
|
||||
{
|
||||
$this->collation = $collation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
20
src/Framework/Database/Schema/Commands/AlterTableCommand.php
Normal file
20
src/Framework/Database/Schema/Commands/AlterTableCommand.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
use App\Framework\Database\Schema\Blueprint;
|
||||
|
||||
final class AlterTableCommand
|
||||
{
|
||||
public readonly string $table;
|
||||
|
||||
public readonly Blueprint $blueprint;
|
||||
|
||||
public function __construct(string $table, Blueprint $blueprint)
|
||||
{
|
||||
$this->table = $table;
|
||||
$this->blueprint = $blueprint;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
use App\Framework\Database\Schema\Blueprint;
|
||||
|
||||
final class CreateTableCommand
|
||||
{
|
||||
public readonly string $table;
|
||||
|
||||
public readonly Blueprint $blueprint;
|
||||
|
||||
public function __construct(string $table, Blueprint $blueprint)
|
||||
{
|
||||
$this->table = $table;
|
||||
$this->blueprint = $blueprint;
|
||||
}
|
||||
}
|
||||
15
src/Framework/Database/Schema/Commands/DropColumnCommand.php
Normal file
15
src/Framework/Database/Schema/Commands/DropColumnCommand.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
final class DropColumnCommand
|
||||
{
|
||||
public readonly array $columns;
|
||||
|
||||
public function __construct(array $columns)
|
||||
{
|
||||
$this->columns = $columns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
final class DropForeignCommand
|
||||
{
|
||||
public readonly string|array $index;
|
||||
|
||||
public function __construct(string|array $index)
|
||||
{
|
||||
$this->index = $index;
|
||||
}
|
||||
}
|
||||
15
src/Framework/Database/Schema/Commands/DropIndexCommand.php
Normal file
15
src/Framework/Database/Schema/Commands/DropIndexCommand.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
final class DropIndexCommand
|
||||
{
|
||||
public readonly string|array $index;
|
||||
|
||||
public function __construct(string|array $index)
|
||||
{
|
||||
$this->index = $index;
|
||||
}
|
||||
}
|
||||
18
src/Framework/Database/Schema/Commands/DropTableCommand.php
Normal file
18
src/Framework/Database/Schema/Commands/DropTableCommand.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
final class DropTableCommand
|
||||
{
|
||||
public readonly string $table;
|
||||
|
||||
public readonly bool $ifExists;
|
||||
|
||||
public function __construct(string $table, bool $ifExists = false)
|
||||
{
|
||||
$this->table = $table;
|
||||
$this->ifExists = $ifExists;
|
||||
}
|
||||
}
|
||||
15
src/Framework/Database/Schema/Commands/RawCommand.php
Normal file
15
src/Framework/Database/Schema/Commands/RawCommand.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
final class RawCommand
|
||||
{
|
||||
public readonly string $sql;
|
||||
|
||||
public function __construct(string $sql)
|
||||
{
|
||||
$this->sql = $sql;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
final class RenameColumnCommand
|
||||
{
|
||||
public readonly string $from;
|
||||
|
||||
public readonly string $to;
|
||||
|
||||
public function __construct(string $from, string $to)
|
||||
{
|
||||
$this->from = $from;
|
||||
$this->to = $to;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
final class RenameTableCommand
|
||||
{
|
||||
public readonly string $from;
|
||||
|
||||
public readonly string $to;
|
||||
|
||||
public function __construct(string $from, string $to)
|
||||
{
|
||||
$this->from = $from;
|
||||
$this->to = $to;
|
||||
}
|
||||
}
|
||||
15
src/Framework/Database/Schema/Commands/SchemaCommand.php
Normal file
15
src/Framework/Database/Schema/Commands/SchemaCommand.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
abstract class SchemaCommand
|
||||
{
|
||||
public readonly string $table;
|
||||
|
||||
public function __construct(string $table)
|
||||
{
|
||||
$this->table = $table;
|
||||
}
|
||||
}
|
||||
108
src/Framework/Database/Schema/Commands/SchemaDiffCommand.php
Normal file
108
src/Framework/Database/Schema/Commands/SchemaDiffCommand.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Commands;
|
||||
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\Database\DatabaseManager;
|
||||
use App\Framework\Database\Schema\Comparison\SchemaComparator;
|
||||
|
||||
/**
|
||||
* Command to display schema differences between two databases
|
||||
*/
|
||||
final readonly class SchemaDiffCommand
|
||||
{
|
||||
public function __construct(
|
||||
private DatabaseManager $databaseManager
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display schema differences
|
||||
*
|
||||
* @param string $sourceConnection The name of the source connection (default: default)
|
||||
* @param string $targetConnection The name of the target connection
|
||||
* @param string|null $sourceSchema The name of the source schema (default: public for PostgreSQL, database name for MySQL)
|
||||
* @param string|null $targetSchema The name of the target schema (default: public for PostgreSQL, database name for MySQL)
|
||||
* @param bool $detailed Whether to show detailed differences
|
||||
* @return ExitCode
|
||||
*/
|
||||
#[ConsoleCommand('db:schema:diff', 'Display schema differences between two databases')]
|
||||
public function __invoke(
|
||||
string $sourceConnection = 'default',
|
||||
string $targetConnection = 'default',
|
||||
?string $sourceSchema = null,
|
||||
?string $targetSchema = null,
|
||||
bool $detailed = false
|
||||
): ExitCode {
|
||||
try {
|
||||
// Get the connections
|
||||
$sourceConn = $this->databaseManager->getConnection($sourceConnection);
|
||||
$targetConn = $this->databaseManager->getConnection($targetConnection);
|
||||
|
||||
// Compare the schemas
|
||||
$comparator = new SchemaComparator($sourceConn, $targetConn);
|
||||
$difference = $comparator->compare($sourceSchema, $targetSchema);
|
||||
|
||||
if (! $difference->hasDifferences()) {
|
||||
echo "No schema differences found.\n";
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// Display a summary of the differences
|
||||
echo "Schema Difference Summary:\n";
|
||||
echo str_repeat('-', 80) . "\n";
|
||||
|
||||
$summary = $difference->getSummary();
|
||||
echo "Source schema: " . ($difference->sourceSchema ?? 'default') . "\n";
|
||||
echo "Target schema: " . ($difference->targetSchema ?? 'default') . "\n";
|
||||
echo "\n";
|
||||
echo "Missing tables: {$summary['missing_tables']}\n";
|
||||
echo "Extra tables: {$summary['extra_tables']}\n";
|
||||
echo "Modified tables: " . count($difference->tableDifferences) . "\n";
|
||||
|
||||
if ($detailed) {
|
||||
echo "\nDetailed Differences:\n";
|
||||
echo str_repeat('-', 80) . "\n";
|
||||
|
||||
// Display missing tables
|
||||
if (! empty($difference->missingTables)) {
|
||||
echo "\nMissing Tables (in target):\n";
|
||||
foreach (array_keys($difference->missingTables) as $tableName) {
|
||||
echo " - {$tableName}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Display extra tables
|
||||
if (! empty($difference->extraTables)) {
|
||||
echo "\nExtra Tables (in target):\n";
|
||||
foreach (array_keys($difference->extraTables) as $tableName) {
|
||||
echo " - {$tableName}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Display table differences
|
||||
if (! empty($difference->tableDifferences)) {
|
||||
echo "\nTable Differences:\n";
|
||||
foreach ($difference->tableDifferences as $tableDiff) {
|
||||
echo "\n" . $tableDiff->getDescription();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo "\nFor detailed differences, use the --detailed flag.\n";
|
||||
}
|
||||
|
||||
return ExitCode::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
echo "Error comparing schemas: {$e->getMessage()}\n";
|
||||
if (isset($_ENV['APP_DEBUG']) && $_ENV['APP_DEBUG']) {
|
||||
echo $e->getTraceAsString() . "\n";
|
||||
}
|
||||
|
||||
return ExitCode::SOFTWARE_ERROR;
|
||||
}
|
||||
}
|
||||
}
|
||||
522
src/Framework/Database/Schema/Comparison/MigrationGenerator.php
Normal file
522
src/Framework/Database/Schema/Comparison/MigrationGenerator.php
Normal file
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Comparison;
|
||||
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
|
||||
/**
|
||||
* Generates migration code from schema differences
|
||||
*/
|
||||
final readonly class MigrationGenerator
|
||||
{
|
||||
/**
|
||||
* Generate migration code from schema differences
|
||||
*
|
||||
* @param SchemaDifference $difference The schema differences
|
||||
* @param string $className The name of the migration class
|
||||
* @param string|null $description The description of the migration
|
||||
* @param MigrationVersion|null $version The version of the migration (generated if null)
|
||||
* @return string The generated migration code
|
||||
*/
|
||||
public function generateMigration(
|
||||
SchemaDifference $difference,
|
||||
string $className,
|
||||
?string $description = null,
|
||||
?MigrationVersion $version = null
|
||||
): string {
|
||||
if (! $difference->hasDifferences()) {
|
||||
throw new \InvalidArgumentException('Cannot generate migration from schema with no differences');
|
||||
}
|
||||
|
||||
$version = $version ?? MigrationVersion::fromDateTime(new \DateTimeImmutable());
|
||||
$description = $description ?? "Schema update from {$difference->sourceSchema} to {$difference->targetSchema}";
|
||||
|
||||
$upCode = $this->generateUpCode($difference);
|
||||
$downCode = $this->generateDownCode($difference);
|
||||
|
||||
return $this->generateMigrationClass($className, $description, $version, $upCode, $downCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the up method code
|
||||
*/
|
||||
private function generateUpCode(SchemaDifference $difference): string
|
||||
{
|
||||
$code = [];
|
||||
|
||||
// Handle missing tables (create them)
|
||||
foreach ($difference->missingTables as $tableName => $tableDef) {
|
||||
$code[] = $this->generateCreateTableCode($tableName, $tableDef);
|
||||
}
|
||||
|
||||
// Handle table differences
|
||||
foreach ($difference->tableDifferences as $tableDifference) {
|
||||
$code[] = $this->generateTableModificationCode($tableDifference);
|
||||
}
|
||||
|
||||
// Handle extra tables (drop them in down method)
|
||||
|
||||
return implode("\n\n", $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the down method code
|
||||
*/
|
||||
private function generateDownCode(SchemaDifference $difference): string
|
||||
{
|
||||
$code = [];
|
||||
|
||||
// Handle extra tables (drop them)
|
||||
foreach ($difference->extraTables as $tableName => $tableDef) {
|
||||
$code[] = " \$schema->dropIfExists('{$tableName}');";
|
||||
}
|
||||
|
||||
// Handle table differences (reverse the changes)
|
||||
foreach ($difference->tableDifferences as $tableDifference) {
|
||||
$code[] = $this->generateReverseTableModificationCode($tableDifference);
|
||||
}
|
||||
|
||||
// Handle missing tables (create them in down method)
|
||||
foreach ($difference->missingTables as $tableName => $tableDef) {
|
||||
$code[] = $this->generateDropTableCode($tableName);
|
||||
}
|
||||
|
||||
return implode("\n\n", $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code to create a table
|
||||
*/
|
||||
private function generateCreateTableCode(string $tableName, array $tableDef): string
|
||||
{
|
||||
$code = [];
|
||||
$code[] = " \$schema->create('{$tableName}', function (\$table) {";
|
||||
|
||||
// Add columns
|
||||
foreach ($tableDef['columns'] ?? [] as $columnName => $columnDef) {
|
||||
$type = $columnDef['type'] ?? 'string';
|
||||
$columnCode = " \$table->{$this->mapColumnType($type)}('{$columnName}')";
|
||||
|
||||
// Add modifiers
|
||||
if (isset($columnDef['length']) && $columnDef['length'] > 0) {
|
||||
$columnCode .= "->length({$columnDef['length']})";
|
||||
}
|
||||
|
||||
if (isset($columnDef['nullable']) && $columnDef['nullable']) {
|
||||
$columnCode .= '->nullable()';
|
||||
}
|
||||
|
||||
if (isset($columnDef['default'])) {
|
||||
$default = $this->formatDefaultValue($columnDef['default'], $type);
|
||||
$columnCode .= "->default({$default})";
|
||||
}
|
||||
|
||||
if (isset($columnDef['unsigned']) && $columnDef['unsigned']) {
|
||||
$columnCode .= '->unsigned()';
|
||||
}
|
||||
|
||||
if (isset($columnDef['autoincrement']) && $columnDef['autoincrement']) {
|
||||
$columnCode .= '->autoIncrement()';
|
||||
}
|
||||
|
||||
$columnCode .= ';';
|
||||
$code[] = $columnCode;
|
||||
}
|
||||
|
||||
// Add indexes
|
||||
foreach ($tableDef['indexes'] ?? [] as $indexName => $indexDef) {
|
||||
$columns = implode("', '", $indexDef['columns'] ?? []);
|
||||
|
||||
if (isset($indexDef['type']) && $indexDef['type'] === 'PRIMARY') {
|
||||
$code[] = " \$table->primary(['{$columns}']);";
|
||||
} elseif (isset($indexDef['unique']) && $indexDef['unique']) {
|
||||
$code[] = " \$table->unique(['{$columns}'], '{$indexName}');";
|
||||
} else {
|
||||
$code[] = " \$table->index(['{$columns}'], '{$indexName}');";
|
||||
}
|
||||
}
|
||||
|
||||
// Add foreign keys
|
||||
foreach ($tableDef['foreign_keys'] ?? [] as $fkName => $fkDef) {
|
||||
$columns = implode("', '", $fkDef['columns'] ?? []);
|
||||
$refTable = $fkDef['referenced_table'] ?? '';
|
||||
$refColumns = implode("', '", $fkDef['referenced_columns'] ?? []);
|
||||
|
||||
$fkCode = " \$table->foreignKey(['{$columns}'], '{$refTable}', ['{$refColumns}'], '{$fkName}')";
|
||||
|
||||
if (isset($fkDef['update_rule'])) {
|
||||
$fkCode .= "->onUpdate('{$fkDef['update_rule']}')";
|
||||
}
|
||||
|
||||
if (isset($fkDef['delete_rule'])) {
|
||||
$fkCode .= "->onDelete('{$fkDef['delete_rule']}')";
|
||||
}
|
||||
|
||||
$fkCode .= ';';
|
||||
$code[] = $fkCode;
|
||||
}
|
||||
|
||||
$code[] = ' });';
|
||||
|
||||
return implode("\n", $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code to drop a table
|
||||
*/
|
||||
private function generateDropTableCode(string $tableName): string
|
||||
{
|
||||
return " \$schema->dropIfExists('{$tableName}');";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code to modify a table
|
||||
*/
|
||||
private function generateTableModificationCode(TableDifference $difference): string
|
||||
{
|
||||
if (! $difference->hasDifferences()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$code = [];
|
||||
$code[] = " \$schema->table('{$difference->tableName}', function (\$table) {";
|
||||
|
||||
// Add missing columns
|
||||
foreach ($difference->missingColumns as $columnName => $columnDef) {
|
||||
$type = $columnDef['type'] ?? 'string';
|
||||
$columnCode = " \$table->add{$this->mapColumnType($type)}('{$columnName}')";
|
||||
|
||||
// Add modifiers
|
||||
if (isset($columnDef['length']) && $columnDef['length'] > 0) {
|
||||
$columnCode .= "->length({$columnDef['length']})";
|
||||
}
|
||||
|
||||
if (isset($columnDef['nullable']) && $columnDef['nullable']) {
|
||||
$columnCode .= '->nullable()';
|
||||
}
|
||||
|
||||
if (isset($columnDef['default'])) {
|
||||
$default = $this->formatDefaultValue($columnDef['default'], $type);
|
||||
$columnCode .= "->default({$default})";
|
||||
}
|
||||
|
||||
if (isset($columnDef['unsigned']) && $columnDef['unsigned']) {
|
||||
$columnCode .= '->unsigned()';
|
||||
}
|
||||
|
||||
$columnCode .= ';';
|
||||
$code[] = $columnCode;
|
||||
}
|
||||
|
||||
// Modify columns
|
||||
foreach ($difference->modifiedColumns as $columnName => $columnDiff) {
|
||||
$targetDef = $columnDiff['target'];
|
||||
$type = $targetDef['type'] ?? 'string';
|
||||
$columnCode = " \$table->modify{$this->mapColumnType($type)}('{$columnName}')";
|
||||
|
||||
// Add modifiers
|
||||
if (isset($targetDef['length']) && $targetDef['length'] > 0) {
|
||||
$columnCode .= "->length({$targetDef['length']})";
|
||||
}
|
||||
|
||||
if (isset($targetDef['nullable']) && $targetDef['nullable']) {
|
||||
$columnCode .= '->nullable()';
|
||||
} elseif (isset($targetDef['nullable']) && ! $targetDef['nullable']) {
|
||||
$columnCode .= '->notNullable()';
|
||||
}
|
||||
|
||||
if (isset($targetDef['default'])) {
|
||||
$default = $this->formatDefaultValue($targetDef['default'], $type);
|
||||
$columnCode .= "->default({$default})";
|
||||
}
|
||||
|
||||
if (isset($targetDef['unsigned']) && $targetDef['unsigned']) {
|
||||
$columnCode .= '->unsigned()';
|
||||
}
|
||||
|
||||
$columnCode .= ';';
|
||||
$code[] = $columnCode;
|
||||
}
|
||||
|
||||
// Drop extra columns
|
||||
foreach ($difference->extraColumns as $columnName => $columnDef) {
|
||||
$code[] = " \$table->dropColumn('{$columnName}');";
|
||||
}
|
||||
|
||||
// Add missing indexes
|
||||
foreach ($difference->missingIndexes as $indexName => $indexDef) {
|
||||
$columns = implode("', '", $indexDef['columns'] ?? []);
|
||||
|
||||
if (isset($indexDef['type']) && $indexDef['type'] === 'PRIMARY') {
|
||||
$code[] = " \$table->primary(['{$columns}']);";
|
||||
} elseif (isset($indexDef['unique']) && $indexDef['unique']) {
|
||||
$code[] = " \$table->unique(['{$columns}'], '{$indexName}');";
|
||||
} else {
|
||||
$code[] = " \$table->index(['{$columns}'], '{$indexName}');";
|
||||
}
|
||||
}
|
||||
|
||||
// Drop extra indexes
|
||||
foreach ($difference->extraIndexes as $indexName => $indexDef) {
|
||||
$code[] = " \$table->dropIndex('{$indexName}');";
|
||||
}
|
||||
|
||||
// Add missing foreign keys
|
||||
foreach ($difference->missingForeignKeys as $fkName => $fkDef) {
|
||||
$columns = implode("', '", $fkDef['columns'] ?? []);
|
||||
$refTable = $fkDef['referenced_table'] ?? '';
|
||||
$refColumns = implode("', '", $fkDef['referenced_columns'] ?? []);
|
||||
|
||||
$fkCode = " \$table->foreignKey(['{$columns}'], '{$refTable}', ['{$refColumns}'], '{$fkName}')";
|
||||
|
||||
if (isset($fkDef['update_rule'])) {
|
||||
$fkCode .= "->onUpdate('{$fkDef['update_rule']}')";
|
||||
}
|
||||
|
||||
if (isset($fkDef['delete_rule'])) {
|
||||
$fkCode .= "->onDelete('{$fkDef['delete_rule']}')";
|
||||
}
|
||||
|
||||
$fkCode .= ';';
|
||||
$code[] = $fkCode;
|
||||
}
|
||||
|
||||
// Drop extra foreign keys
|
||||
foreach ($difference->extraForeignKeys as $fkName => $fkDef) {
|
||||
$code[] = " \$table->dropForeignKey('{$fkName}');";
|
||||
}
|
||||
|
||||
$code[] = ' });';
|
||||
|
||||
return implode("\n", $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code to reverse table modifications
|
||||
*/
|
||||
private function generateReverseTableModificationCode(TableDifference $difference): string
|
||||
{
|
||||
if (! $difference->hasDifferences()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$code = [];
|
||||
$code[] = " \$schema->table('{$difference->tableName}', function (\$table) {";
|
||||
|
||||
// Drop missing columns (they were added in up)
|
||||
foreach ($difference->missingColumns as $columnName => $columnDef) {
|
||||
$code[] = " \$table->dropColumn('{$columnName}');";
|
||||
}
|
||||
|
||||
// Restore modified columns to original state
|
||||
foreach ($difference->modifiedColumns as $columnName => $columnDiff) {
|
||||
$sourceDef = $columnDiff['source'];
|
||||
$type = $sourceDef['type'] ?? 'string';
|
||||
$columnCode = " \$table->modify{$this->mapColumnType($type)}('{$columnName}')";
|
||||
|
||||
// Add modifiers
|
||||
if (isset($sourceDef['length']) && $sourceDef['length'] > 0) {
|
||||
$columnCode .= "->length({$sourceDef['length']})";
|
||||
}
|
||||
|
||||
if (isset($sourceDef['nullable']) && $sourceDef['nullable']) {
|
||||
$columnCode .= '->nullable()';
|
||||
} elseif (isset($sourceDef['nullable']) && ! $sourceDef['nullable']) {
|
||||
$columnCode .= '->notNullable()';
|
||||
}
|
||||
|
||||
if (isset($sourceDef['default'])) {
|
||||
$default = $this->formatDefaultValue($sourceDef['default'], $type);
|
||||
$columnCode .= "->default({$default})";
|
||||
}
|
||||
|
||||
if (isset($sourceDef['unsigned']) && $sourceDef['unsigned']) {
|
||||
$columnCode .= '->unsigned()';
|
||||
}
|
||||
|
||||
$columnCode .= ';';
|
||||
$code[] = $columnCode;
|
||||
}
|
||||
|
||||
// Add extra columns (they were dropped in up)
|
||||
foreach ($difference->extraColumns as $columnName => $columnDef) {
|
||||
$type = $columnDef['type'] ?? 'string';
|
||||
$columnCode = " \$table->add{$this->mapColumnType($type)}('{$columnName}')";
|
||||
|
||||
// Add modifiers
|
||||
if (isset($columnDef['length']) && $columnDef['length'] > 0) {
|
||||
$columnCode .= "->length({$columnDef['length']})";
|
||||
}
|
||||
|
||||
if (isset($columnDef['nullable']) && $columnDef['nullable']) {
|
||||
$columnCode .= '->nullable()';
|
||||
}
|
||||
|
||||
if (isset($columnDef['default'])) {
|
||||
$default = $this->formatDefaultValue($columnDef['default'], $type);
|
||||
$columnCode .= "->default({$default})";
|
||||
}
|
||||
|
||||
if (isset($columnDef['unsigned']) && $columnDef['unsigned']) {
|
||||
$columnCode .= '->unsigned()';
|
||||
}
|
||||
|
||||
$columnCode .= ';';
|
||||
$code[] = $columnCode;
|
||||
}
|
||||
|
||||
// Drop missing indexes (they were added in up)
|
||||
foreach ($difference->missingIndexes as $indexName => $indexDef) {
|
||||
$code[] = " \$table->dropIndex('{$indexName}');";
|
||||
}
|
||||
|
||||
// Add extra indexes (they were dropped in up)
|
||||
foreach ($difference->extraIndexes as $indexName => $indexDef) {
|
||||
$columns = implode("', '", $indexDef['columns'] ?? []);
|
||||
|
||||
if (isset($indexDef['type']) && $indexDef['type'] === 'PRIMARY') {
|
||||
$code[] = " \$table->primary(['{$columns}']);";
|
||||
} elseif (isset($indexDef['unique']) && $indexDef['unique']) {
|
||||
$code[] = " \$table->unique(['{$columns}'], '{$indexName}');";
|
||||
} else {
|
||||
$code[] = " \$table->index(['{$columns}'], '{$indexName}');";
|
||||
}
|
||||
}
|
||||
|
||||
// Drop missing foreign keys (they were added in up)
|
||||
foreach ($difference->missingForeignKeys as $fkName => $fkDef) {
|
||||
$code[] = " \$table->dropForeignKey('{$fkName}');";
|
||||
}
|
||||
|
||||
// Add extra foreign keys (they were dropped in up)
|
||||
foreach ($difference->extraForeignKeys as $fkName => $fkDef) {
|
||||
$columns = implode("', '", $fkDef['columns'] ?? []);
|
||||
$refTable = $fkDef['referenced_table'] ?? '';
|
||||
$refColumns = implode("', '", $fkDef['referenced_columns'] ?? []);
|
||||
|
||||
$fkCode = " \$table->foreignKey(['{$columns}'], '{$refTable}', ['{$refColumns}'], '{$fkName}')";
|
||||
|
||||
if (isset($fkDef['update_rule'])) {
|
||||
$fkCode .= "->onUpdate('{$fkDef['update_rule']}')";
|
||||
}
|
||||
|
||||
if (isset($fkDef['delete_rule'])) {
|
||||
$fkCode .= "->onDelete('{$fkDef['delete_rule']}')";
|
||||
}
|
||||
|
||||
$fkCode .= ';';
|
||||
$code[] = $fkCode;
|
||||
}
|
||||
|
||||
$code[] = ' });';
|
||||
|
||||
return implode("\n", $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the migration class
|
||||
*/
|
||||
private function generateMigrationClass(
|
||||
string $className,
|
||||
string $description,
|
||||
MigrationVersion $version,
|
||||
string $upCode,
|
||||
string $downCode
|
||||
): string {
|
||||
$template = <<<PHP
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migration;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Migration\AbstractMigration;
|
||||
use App\Framework\Database\Migration\MigrationVersion;
|
||||
use App\Framework\Database\Schema\Schema;
|
||||
|
||||
final class {$className} extends AbstractMigration
|
||||
{
|
||||
public function getVersion(): MigrationVersion
|
||||
{
|
||||
return MigrationVersion::fromString('{$version->toString()}');
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '{$description}';
|
||||
}
|
||||
|
||||
public function up(ConnectionInterface \$connection): void
|
||||
{
|
||||
\$schema = new Schema(\$connection);
|
||||
|
||||
{$upCode}
|
||||
|
||||
\$schema->execute();
|
||||
}
|
||||
|
||||
public function down(ConnectionInterface \$connection): void
|
||||
{
|
||||
\$schema = new Schema(\$connection);
|
||||
|
||||
{$downCode}
|
||||
|
||||
\$schema->execute();
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database column type to schema builder method
|
||||
*/
|
||||
private function mapColumnType(string $type): string
|
||||
{
|
||||
return match (strtolower($type)) {
|
||||
'int', 'integer' => 'integer',
|
||||
'bigint' => 'bigInteger',
|
||||
'smallint' => 'smallInteger',
|
||||
'tinyint' => 'tinyInteger',
|
||||
'float', 'double', 'real' => 'float',
|
||||
'decimal', 'numeric' => 'decimal',
|
||||
'boolean', 'bool' => 'boolean',
|
||||
'date' => 'date',
|
||||
'datetime' => 'dateTime',
|
||||
'timestamp' => 'timestamp',
|
||||
'time' => 'time',
|
||||
'char' => 'char',
|
||||
'varchar', 'string' => 'string',
|
||||
'text' => 'text',
|
||||
'mediumtext' => 'mediumText',
|
||||
'longtext' => 'longText',
|
||||
'binary' => 'binary',
|
||||
'blob' => 'binary',
|
||||
'json' => 'json',
|
||||
'jsonb' => 'jsonb',
|
||||
'uuid' => 'uuid',
|
||||
'enum' => 'enum',
|
||||
default => 'string',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format default value for use in migration code
|
||||
*/
|
||||
private function formatDefaultValue(mixed $value, string $type): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
return match (strtolower($type)) {
|
||||
'int', 'integer', 'bigint', 'smallint', 'tinyint', 'float', 'double', 'real', 'decimal', 'numeric' => (string) $value,
|
||||
'boolean', 'bool' => $value ? 'true' : 'false',
|
||||
default => "'{$value}'",
|
||||
};
|
||||
}
|
||||
}
|
||||
788
src/Framework/Database/Schema/Comparison/SchemaComparator.php
Normal file
788
src/Framework/Database/Schema/Comparison/SchemaComparator.php
Normal file
@@ -0,0 +1,788 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Comparison;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
|
||||
/**
|
||||
* Compares database schemas and identifies differences
|
||||
*/
|
||||
final readonly class SchemaComparator
|
||||
{
|
||||
/**
|
||||
* Create a new schema comparator
|
||||
*/
|
||||
public function __construct(
|
||||
private ConnectionInterface $sourceConnection,
|
||||
private ConnectionInterface $targetConnection
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the schemas of the source and target databases
|
||||
*
|
||||
* @param string|null $sourceSchema The schema name in the source database (null for default)
|
||||
* @param string|null $targetSchema The schema name in the target database (null for default)
|
||||
* @return SchemaDifference The differences between the schemas
|
||||
*/
|
||||
public function compare(?string $sourceSchema = null, ?string $targetSchema = null): SchemaDifference
|
||||
{
|
||||
$sourceDriver = $this->getDriverName($this->sourceConnection);
|
||||
$targetDriver = $this->getDriverName($this->targetConnection);
|
||||
|
||||
if ($sourceDriver !== $targetDriver) {
|
||||
throw new DatabaseException(
|
||||
"Cannot compare schemas between different database drivers: {$sourceDriver} and {$targetDriver}"
|
||||
);
|
||||
}
|
||||
|
||||
return match($sourceDriver) {
|
||||
'mysql' => $this->compareMySql($sourceSchema, $targetSchema),
|
||||
'pgsql' => $this->comparePostgreSql($sourceSchema, $targetSchema),
|
||||
'sqlite' => $this->compareSqlite(),
|
||||
default => throw new DatabaseException("Schema comparison not supported for {$sourceDriver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare MySQL schemas
|
||||
*/
|
||||
private function compareMySql(?string $sourceSchema = null, ?string $targetSchema = null): SchemaDifference
|
||||
{
|
||||
// Use the connection's database if schema is not specified
|
||||
$sourceSchema = $sourceSchema ?? $this->getDefaultSchema($this->sourceConnection);
|
||||
$targetSchema = $targetSchema ?? $this->getDefaultSchema($this->targetConnection);
|
||||
|
||||
// Compare tables
|
||||
$sourceTables = $this->getMySqlTables($this->sourceConnection, $sourceSchema);
|
||||
$targetTables = $this->getMySqlTables($this->targetConnection, $targetSchema);
|
||||
|
||||
$missingTables = array_diff($sourceTables, $targetTables);
|
||||
$extraTables = array_diff($targetTables, $sourceTables);
|
||||
$commonTables = array_intersect($sourceTables, $targetTables);
|
||||
|
||||
// Compare table structures for common tables
|
||||
$tableDifferences = [];
|
||||
foreach ($commonTables as $table) {
|
||||
$sourceColumns = $this->getMySqlColumns($this->sourceConnection, $sourceSchema, $table);
|
||||
$targetColumns = $this->getMySqlColumns($this->targetConnection, $targetSchema, $table);
|
||||
|
||||
$missingColumns = array_diff_key($sourceColumns, $targetColumns);
|
||||
$extraColumns = array_diff_key($targetColumns, $sourceColumns);
|
||||
|
||||
// Check for column differences
|
||||
$modifiedColumns = [];
|
||||
foreach (array_intersect_key($sourceColumns, $targetColumns) as $columnName => $sourceColumn) {
|
||||
$targetColumn = $targetColumns[$columnName];
|
||||
|
||||
if ($sourceColumn !== $targetColumn) {
|
||||
$modifiedColumns[$columnName] = [
|
||||
'source' => $sourceColumn,
|
||||
'target' => $targetColumn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Compare indexes
|
||||
$sourceIndexes = $this->getMySqlIndexes($this->sourceConnection, $sourceSchema, $table);
|
||||
$targetIndexes = $this->getMySqlIndexes($this->targetConnection, $targetSchema, $table);
|
||||
|
||||
$missingIndexes = array_diff_key($sourceIndexes, $targetIndexes);
|
||||
$extraIndexes = array_diff_key($targetIndexes, $sourceIndexes);
|
||||
|
||||
// Check for index differences
|
||||
$modifiedIndexes = [];
|
||||
foreach (array_intersect_key($sourceIndexes, $targetIndexes) as $indexName => $sourceIndex) {
|
||||
$targetIndex = $targetIndexes[$indexName];
|
||||
|
||||
if ($sourceIndex !== $targetIndex) {
|
||||
$modifiedIndexes[$indexName] = [
|
||||
'source' => $sourceIndex,
|
||||
'target' => $targetIndex,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Compare foreign keys
|
||||
$sourceForeignKeys = $this->getMySqlForeignKeys($this->sourceConnection, $sourceSchema, $table);
|
||||
$targetForeignKeys = $this->getMySqlForeignKeys($this->targetConnection, $targetSchema, $table);
|
||||
|
||||
$missingForeignKeys = array_diff_key($sourceForeignKeys, $targetForeignKeys);
|
||||
$extraForeignKeys = array_diff_key($targetForeignKeys, $sourceForeignKeys);
|
||||
|
||||
// Check for foreign key differences
|
||||
$modifiedForeignKeys = [];
|
||||
foreach (array_intersect_key($sourceForeignKeys, $targetForeignKeys) as $fkName => $sourceFk) {
|
||||
$targetFk = $targetForeignKeys[$fkName];
|
||||
|
||||
if ($sourceFk !== $targetFk) {
|
||||
$modifiedForeignKeys[$fkName] = [
|
||||
'source' => $sourceFk,
|
||||
'target' => $targetFk,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add table differences if any
|
||||
if (! empty($missingColumns) || ! empty($extraColumns) || ! empty($modifiedColumns) ||
|
||||
! empty($missingIndexes) || ! empty($extraIndexes) || ! empty($modifiedIndexes) ||
|
||||
! empty($missingForeignKeys) || ! empty($extraForeignKeys) || ! empty($modifiedForeignKeys)) {
|
||||
|
||||
$tableDifferences[$table] = new TableDifference(
|
||||
$table,
|
||||
$missingColumns,
|
||||
$extraColumns,
|
||||
$modifiedColumns,
|
||||
$missingIndexes,
|
||||
$extraIndexes,
|
||||
$modifiedIndexes,
|
||||
$missingForeignKeys,
|
||||
$extraForeignKeys,
|
||||
$modifiedForeignKeys
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new SchemaDifference(
|
||||
$sourceSchema,
|
||||
$targetSchema,
|
||||
$missingTables,
|
||||
$extraTables,
|
||||
$tableDifferences
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare PostgreSQL schemas
|
||||
*/
|
||||
private function comparePostgreSql(?string $sourceSchema = null, ?string $targetSchema = null): SchemaDifference
|
||||
{
|
||||
// Use 'public' as the default schema if not specified
|
||||
$sourceSchema = $sourceSchema ?? 'public';
|
||||
$targetSchema = $targetSchema ?? 'public';
|
||||
|
||||
// Compare tables
|
||||
$sourceTables = $this->getPostgreSqlTables($this->sourceConnection, $sourceSchema);
|
||||
$targetTables = $this->getPostgreSqlTables($this->targetConnection, $targetSchema);
|
||||
|
||||
$missingTables = array_diff($sourceTables, $targetTables);
|
||||
$extraTables = array_diff($targetTables, $sourceTables);
|
||||
$commonTables = array_intersect($sourceTables, $targetTables);
|
||||
|
||||
// Compare table structures for common tables
|
||||
$tableDifferences = [];
|
||||
foreach ($commonTables as $table) {
|
||||
$sourceColumns = $this->getPostgreSqlColumns($this->sourceConnection, $sourceSchema, $table);
|
||||
$targetColumns = $this->getPostgreSqlColumns($this->targetConnection, $targetSchema, $table);
|
||||
|
||||
$missingColumns = array_diff_key($sourceColumns, $targetColumns);
|
||||
$extraColumns = array_diff_key($targetColumns, $sourceColumns);
|
||||
|
||||
// Check for column differences
|
||||
$modifiedColumns = [];
|
||||
foreach (array_intersect_key($sourceColumns, $targetColumns) as $columnName => $sourceColumn) {
|
||||
$targetColumn = $targetColumns[$columnName];
|
||||
|
||||
if ($sourceColumn !== $targetColumn) {
|
||||
$modifiedColumns[$columnName] = [
|
||||
'source' => $sourceColumn,
|
||||
'target' => $targetColumn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Compare indexes
|
||||
$sourceIndexes = $this->getPostgreSqlIndexes($this->sourceConnection, $sourceSchema, $table);
|
||||
$targetIndexes = $this->getPostgreSqlIndexes($this->targetConnection, $targetSchema, $table);
|
||||
|
||||
$missingIndexes = array_diff_key($sourceIndexes, $targetIndexes);
|
||||
$extraIndexes = array_diff_key($targetIndexes, $sourceIndexes);
|
||||
|
||||
// Check for index differences
|
||||
$modifiedIndexes = [];
|
||||
foreach (array_intersect_key($sourceIndexes, $targetIndexes) as $indexName => $sourceIndex) {
|
||||
$targetIndex = $targetIndexes[$indexName];
|
||||
|
||||
if ($sourceIndex !== $targetIndex) {
|
||||
$modifiedIndexes[$indexName] = [
|
||||
'source' => $sourceIndex,
|
||||
'target' => $targetIndex,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Compare foreign keys
|
||||
$sourceForeignKeys = $this->getPostgreSqlForeignKeys($this->sourceConnection, $sourceSchema, $table);
|
||||
$targetForeignKeys = $this->getPostgreSqlForeignKeys($this->targetConnection, $targetSchema, $table);
|
||||
|
||||
$missingForeignKeys = array_diff_key($sourceForeignKeys, $targetForeignKeys);
|
||||
$extraForeignKeys = array_diff_key($targetForeignKeys, $sourceForeignKeys);
|
||||
|
||||
// Check for foreign key differences
|
||||
$modifiedForeignKeys = [];
|
||||
foreach (array_intersect_key($sourceForeignKeys, $targetForeignKeys) as $fkName => $sourceFk) {
|
||||
$targetFk = $targetForeignKeys[$fkName];
|
||||
|
||||
if ($sourceFk !== $targetFk) {
|
||||
$modifiedForeignKeys[$fkName] = [
|
||||
'source' => $sourceFk,
|
||||
'target' => $targetFk,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add table differences if any
|
||||
if (! empty($missingColumns) || ! empty($extraColumns) || ! empty($modifiedColumns) ||
|
||||
! empty($missingIndexes) || ! empty($extraIndexes) || ! empty($modifiedIndexes) ||
|
||||
! empty($missingForeignKeys) || ! empty($extraForeignKeys) || ! empty($modifiedForeignKeys)) {
|
||||
|
||||
$tableDifferences[$table] = new TableDifference(
|
||||
$table,
|
||||
$missingColumns,
|
||||
$extraColumns,
|
||||
$modifiedColumns,
|
||||
$missingIndexes,
|
||||
$extraIndexes,
|
||||
$modifiedIndexes,
|
||||
$missingForeignKeys,
|
||||
$extraForeignKeys,
|
||||
$modifiedForeignKeys
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new SchemaDifference(
|
||||
$sourceSchema,
|
||||
$targetSchema,
|
||||
$missingTables,
|
||||
$extraTables,
|
||||
$tableDifferences
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare SQLite schemas
|
||||
*/
|
||||
private function compareSqlite(): SchemaDifference
|
||||
{
|
||||
// SQLite doesn't have schemas, so we compare the entire database
|
||||
|
||||
// Compare tables
|
||||
$sourceTables = $this->getSqliteTables($this->sourceConnection);
|
||||
$targetTables = $this->getSqliteTables($this->targetConnection);
|
||||
|
||||
$missingTables = array_diff($sourceTables, $targetTables);
|
||||
$extraTables = array_diff($targetTables, $sourceTables);
|
||||
$commonTables = array_intersect($sourceTables, $targetTables);
|
||||
|
||||
// Compare table structures for common tables
|
||||
$tableDifferences = [];
|
||||
foreach ($commonTables as $table) {
|
||||
$sourceColumns = $this->getSqliteColumns($this->sourceConnection, $table);
|
||||
$targetColumns = $this->getSqliteColumns($this->targetConnection, $table);
|
||||
|
||||
$missingColumns = array_diff_key($sourceColumns, $targetColumns);
|
||||
$extraColumns = array_diff_key($targetColumns, $sourceColumns);
|
||||
|
||||
// Check for column differences
|
||||
$modifiedColumns = [];
|
||||
foreach (array_intersect_key($sourceColumns, $targetColumns) as $columnName => $sourceColumn) {
|
||||
$targetColumn = $targetColumns[$columnName];
|
||||
|
||||
if ($sourceColumn !== $targetColumn) {
|
||||
$modifiedColumns[$columnName] = [
|
||||
'source' => $sourceColumn,
|
||||
'target' => $targetColumn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Compare indexes
|
||||
$sourceIndexes = $this->getSqliteIndexes($this->sourceConnection, $table);
|
||||
$targetIndexes = $this->getSqliteIndexes($this->targetConnection, $table);
|
||||
|
||||
$missingIndexes = array_diff_key($sourceIndexes, $targetIndexes);
|
||||
$extraIndexes = array_diff_key($targetIndexes, $sourceIndexes);
|
||||
|
||||
// Check for index differences
|
||||
$modifiedIndexes = [];
|
||||
foreach (array_intersect_key($sourceIndexes, $targetIndexes) as $indexName => $sourceIndex) {
|
||||
$targetIndex = $targetIndexes[$indexName];
|
||||
|
||||
if ($sourceIndex !== $targetIndex) {
|
||||
$modifiedIndexes[$indexName] = [
|
||||
'source' => $sourceIndex,
|
||||
'target' => $targetIndex,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Compare foreign keys
|
||||
$sourceForeignKeys = $this->getSqliteForeignKeys($this->sourceConnection, $table);
|
||||
$targetForeignKeys = $this->getSqliteForeignKeys($this->targetConnection, $table);
|
||||
|
||||
$missingForeignKeys = array_diff_key($sourceForeignKeys, $targetForeignKeys);
|
||||
$extraForeignKeys = array_diff_key($targetForeignKeys, $sourceForeignKeys);
|
||||
|
||||
// Check for foreign key differences
|
||||
$modifiedForeignKeys = [];
|
||||
foreach (array_intersect_key($sourceForeignKeys, $targetForeignKeys) as $fkName => $sourceFk) {
|
||||
$targetFk = $targetForeignKeys[$fkName];
|
||||
|
||||
if ($sourceFk !== $targetFk) {
|
||||
$modifiedForeignKeys[$fkName] = [
|
||||
'source' => $sourceFk,
|
||||
'target' => $targetFk,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add table differences if any
|
||||
if (! empty($missingColumns) || ! empty($extraColumns) || ! empty($modifiedColumns) ||
|
||||
! empty($missingIndexes) || ! empty($extraIndexes) || ! empty($modifiedIndexes) ||
|
||||
! empty($missingForeignKeys) || ! empty($extraForeignKeys) || ! empty($modifiedForeignKeys)) {
|
||||
|
||||
$tableDifferences[$table] = new TableDifference(
|
||||
$table,
|
||||
$missingColumns,
|
||||
$extraColumns,
|
||||
$modifiedColumns,
|
||||
$missingIndexes,
|
||||
$extraIndexes,
|
||||
$modifiedIndexes,
|
||||
$missingForeignKeys,
|
||||
$extraForeignKeys,
|
||||
$modifiedForeignKeys
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new SchemaDifference(
|
||||
'main',
|
||||
'main',
|
||||
$missingTables,
|
||||
$extraTables,
|
||||
$tableDifferences
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default schema for a connection
|
||||
*/
|
||||
private function getDefaultSchema(ConnectionInterface $connection): string
|
||||
{
|
||||
$driver = $this->getDriverName($connection);
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $connection->queryScalar('SELECT DATABASE()'),
|
||||
'pgsql' => 'public',
|
||||
'sqlite' => 'main',
|
||||
default => throw new DatabaseException("Unsupported driver: {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the driver name for a connection
|
||||
*/
|
||||
private function getDriverName(ConnectionInterface $connection): string
|
||||
{
|
||||
return $connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tables in a MySQL schema
|
||||
*
|
||||
* @return array<string> List of table names
|
||||
*/
|
||||
private function getMySqlTables(ConnectionInterface $connection, string $schema): array
|
||||
{
|
||||
$sql = "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'";
|
||||
|
||||
return $connection->queryColumn($sql, [$schema]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get columns in a MySQL table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of column names to column definitions
|
||||
*/
|
||||
private function getMySqlColumns(ConnectionInterface $connection, string $schema, string $table): array
|
||||
{
|
||||
$sql = "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, CHARACTER_SET_NAME, COLLATION_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION";
|
||||
|
||||
$columns = [];
|
||||
$result = $connection->query($sql, [$schema, $table]);
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$columns[$row['COLUMN_NAME']] = [
|
||||
'type' => $row['COLUMN_TYPE'],
|
||||
'nullable' => $row['IS_NULLABLE'] === 'YES',
|
||||
'default' => $row['COLUMN_DEFAULT'],
|
||||
'extra' => $row['EXTRA'],
|
||||
'character_set' => $row['CHARACTER_SET_NAME'],
|
||||
'collation' => $row['COLLATION_NAME'],
|
||||
];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get indexes in a MySQL table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of index names to index definitions
|
||||
*/
|
||||
private function getMySqlIndexes(ConnectionInterface $connection, string $schema, string $table): array
|
||||
{
|
||||
$sql = "SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME, SEQ_IN_INDEX, INDEX_TYPE
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
|
||||
ORDER BY INDEX_NAME, SEQ_IN_INDEX";
|
||||
|
||||
$result = $connection->query($sql, [$schema, $table]);
|
||||
$indexes = [];
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$indexName = $row['INDEX_NAME'];
|
||||
|
||||
if (! isset($indexes[$indexName])) {
|
||||
$indexes[$indexName] = [
|
||||
'unique' => ! $row['NON_UNIQUE'],
|
||||
'type' => $row['INDEX_TYPE'],
|
||||
'columns' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$indexes[$indexName]['columns'][$row['SEQ_IN_INDEX']] = $row['COLUMN_NAME'];
|
||||
}
|
||||
|
||||
// Sort columns by sequence
|
||||
foreach ($indexes as &$index) {
|
||||
ksort($index['columns']);
|
||||
$index['columns'] = array_values($index['columns']);
|
||||
}
|
||||
|
||||
return $indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get foreign keys in a MySQL table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of foreign key names to foreign key definitions
|
||||
*/
|
||||
private function getMySqlForeignKeys(ConnectionInterface $connection, string $schema, string $table): array
|
||||
{
|
||||
$sql = "SELECT CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME,
|
||||
UPDATE_RULE, DELETE_RULE
|
||||
FROM information_schema.KEY_COLUMN_USAGE k
|
||||
JOIN information_schema.REFERENTIAL_CONSTRAINTS r
|
||||
ON k.CONSTRAINT_NAME = r.CONSTRAINT_NAME
|
||||
AND k.CONSTRAINT_SCHEMA = r.CONSTRAINT_SCHEMA
|
||||
WHERE k.TABLE_SCHEMA = ? AND k.TABLE_NAME = ?
|
||||
AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
||||
ORDER BY CONSTRAINT_NAME, ORDINAL_POSITION";
|
||||
|
||||
$result = $connection->query($sql, [$schema, $table]);
|
||||
$foreignKeys = [];
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$constraintName = $row['CONSTRAINT_NAME'];
|
||||
|
||||
if (! isset($foreignKeys[$constraintName])) {
|
||||
$foreignKeys[$constraintName] = [
|
||||
'referenced_table' => $row['REFERENCED_TABLE_NAME'],
|
||||
'update_rule' => $row['UPDATE_RULE'],
|
||||
'delete_rule' => $row['DELETE_RULE'],
|
||||
'columns' => [],
|
||||
'referenced_columns' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$foreignKeys[$constraintName]['columns'][] = $row['COLUMN_NAME'];
|
||||
$foreignKeys[$constraintName]['referenced_columns'][] = $row['REFERENCED_COLUMN_NAME'];
|
||||
}
|
||||
|
||||
return $foreignKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tables in a PostgreSQL schema
|
||||
*
|
||||
* @return array<string> List of table names
|
||||
*/
|
||||
private function getPostgreSqlTables(ConnectionInterface $connection, string $schema): array
|
||||
{
|
||||
$sql = "SELECT tablename FROM pg_tables WHERE schemaname = ?";
|
||||
|
||||
return $connection->queryColumn($sql, [$schema]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get columns in a PostgreSQL table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of column names to column definitions
|
||||
*/
|
||||
private function getPostgreSqlColumns(ConnectionInterface $connection, string $schema, string $table): array
|
||||
{
|
||||
$sql = "SELECT a.attname as column_name,
|
||||
pg_catalog.format_type(a.atttypid, a.atttypmod) as data_type,
|
||||
a.attnotnull as not_null,
|
||||
pg_get_expr(d.adbin, d.adrelid) as default_value,
|
||||
col_description(a.attrelid, a.attnum) as comment
|
||||
FROM pg_catalog.pg_attribute a
|
||||
LEFT JOIN pg_catalog.pg_attrdef d ON (a.attrelid, a.attnum) = (d.adrelid, d.adnum)
|
||||
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid
|
||||
WHERE c.relname = ?
|
||||
AND n.nspname = ?
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
ORDER BY a.attnum";
|
||||
|
||||
$columns = [];
|
||||
$result = $connection->query($sql, [$table, $schema]);
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$columns[$row['column_name']] = [
|
||||
'type' => $row['data_type'],
|
||||
'nullable' => ! $row['not_null'],
|
||||
'default' => $row['default_value'],
|
||||
'comment' => $row['comment'],
|
||||
];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get indexes in a PostgreSQL table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of index names to index definitions
|
||||
*/
|
||||
private function getPostgreSqlIndexes(ConnectionInterface $connection, string $schema, string $table): array
|
||||
{
|
||||
$sql = "SELECT i.relname as index_name,
|
||||
a.attname as column_name,
|
||||
ix.indisunique as is_unique,
|
||||
ix.indisprimary as is_primary,
|
||||
am.amname as index_type,
|
||||
a.attnum as column_position
|
||||
FROM pg_index ix
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_class t ON t.oid = ix.indrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
||||
JOIN pg_am am ON i.relam = am.oid
|
||||
WHERE t.relname = ?
|
||||
AND n.nspname = ?
|
||||
ORDER BY i.relname, a.attnum";
|
||||
|
||||
$result = $connection->query($sql, [$table, $schema]);
|
||||
$indexes = [];
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$indexName = $row['index_name'];
|
||||
|
||||
if (! isset($indexes[$indexName])) {
|
||||
$indexes[$indexName] = [
|
||||
'unique' => (bool)$row['is_unique'],
|
||||
'primary' => (bool)$row['is_primary'],
|
||||
'type' => $row['index_type'],
|
||||
'columns' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$indexes[$indexName]['columns'][$row['column_position']] = $row['column_name'];
|
||||
}
|
||||
|
||||
// Sort columns by position
|
||||
foreach ($indexes as &$index) {
|
||||
ksort($index['columns']);
|
||||
$index['columns'] = array_values($index['columns']);
|
||||
}
|
||||
|
||||
return $indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get foreign keys in a PostgreSQL table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of foreign key names to foreign key definitions
|
||||
*/
|
||||
private function getPostgreSqlForeignKeys(ConnectionInterface $connection, string $schema, string $table): array
|
||||
{
|
||||
$sql = "SELECT con.conname as constraint_name,
|
||||
con.confupdtype as update_rule,
|
||||
con.confdeltype as delete_rule,
|
||||
f.relname as referenced_table,
|
||||
a.attname as column_name,
|
||||
af.attname as referenced_column,
|
||||
a.attnum as column_position
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class t ON t.oid = con.conrelid
|
||||
JOIN pg_class f ON f.oid = con.confrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(con.conkey)
|
||||
JOIN pg_attribute af ON af.attrelid = f.oid AND af.attnum = ANY(con.confkey)
|
||||
WHERE con.contype = 'f'
|
||||
AND t.relname = ?
|
||||
AND n.nspname = ?
|
||||
ORDER BY con.conname, a.attnum";
|
||||
|
||||
$result = $connection->query($sql, [$table, $schema]);
|
||||
$foreignKeys = [];
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$constraintName = $row['constraint_name'];
|
||||
|
||||
if (! isset($foreignKeys[$constraintName])) {
|
||||
$foreignKeys[$constraintName] = [
|
||||
'referenced_table' => $row['referenced_table'],
|
||||
'update_rule' => $this->convertPostgreSqlRule($row['update_rule']),
|
||||
'delete_rule' => $this->convertPostgreSqlRule($row['delete_rule']),
|
||||
'columns' => [],
|
||||
'referenced_columns' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$foreignKeys[$constraintName]['columns'][$row['column_position']] = $row['column_name'];
|
||||
$foreignKeys[$constraintName]['referenced_columns'][$row['column_position']] = $row['referenced_column'];
|
||||
}
|
||||
|
||||
// Sort columns by position
|
||||
foreach ($foreignKeys as &$fk) {
|
||||
ksort($fk['columns']);
|
||||
ksort($fk['referenced_columns']);
|
||||
$fk['columns'] = array_values($fk['columns']);
|
||||
$fk['referenced_columns'] = array_values($fk['referenced_columns']);
|
||||
}
|
||||
|
||||
return $foreignKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PostgreSQL rule character to rule name
|
||||
*/
|
||||
private function convertPostgreSqlRule(string $rule): string
|
||||
{
|
||||
return match($rule) {
|
||||
'a' => 'NO ACTION',
|
||||
'r' => 'RESTRICT',
|
||||
'c' => 'CASCADE',
|
||||
'n' => 'SET NULL',
|
||||
'd' => 'SET DEFAULT',
|
||||
default => $rule
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tables in a SQLite database
|
||||
*
|
||||
* @return array<string> List of table names
|
||||
*/
|
||||
private function getSqliteTables(ConnectionInterface $connection): array
|
||||
{
|
||||
$sql = "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'";
|
||||
|
||||
return $connection->queryColumn($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get columns in a SQLite table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of column names to column definitions
|
||||
*/
|
||||
private function getSqliteColumns(ConnectionInterface $connection, string $table): array
|
||||
{
|
||||
$sql = "PRAGMA table_info({$table})";
|
||||
$result = $connection->query($sql);
|
||||
$columns = [];
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$columns[$row['name']] = [
|
||||
'type' => $row['type'],
|
||||
'nullable' => ! $row['notnull'],
|
||||
'default' => $row['dflt_value'],
|
||||
'primary' => (bool)$row['pk'],
|
||||
];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get indexes in a SQLite table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of index names to index definitions
|
||||
*/
|
||||
private function getSqliteIndexes(ConnectionInterface $connection, string $table): array
|
||||
{
|
||||
$sql = "PRAGMA index_list({$table})";
|
||||
$result = $connection->query($sql);
|
||||
$indexes = [];
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$indexName = $row['name'];
|
||||
$indexInfo = $connection->query("PRAGMA index_info({$indexName})")->fetchAll();
|
||||
|
||||
$columns = [];
|
||||
foreach ($indexInfo as $column) {
|
||||
$columns[$column['seqno']] = $column['name'];
|
||||
}
|
||||
|
||||
ksort($columns);
|
||||
|
||||
$indexes[$indexName] = [
|
||||
'unique' => (bool)$row['unique'],
|
||||
'columns' => array_values($columns),
|
||||
];
|
||||
}
|
||||
|
||||
return $indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get foreign keys in a SQLite table
|
||||
*
|
||||
* @return array<string, array<string, mixed>> Map of foreign key names to foreign key definitions
|
||||
*/
|
||||
private function getSqliteForeignKeys(ConnectionInterface $connection, string $table): array
|
||||
{
|
||||
$sql = "PRAGMA foreign_key_list({$table})";
|
||||
$result = $connection->query($sql);
|
||||
$foreignKeys = [];
|
||||
|
||||
foreach ($result->fetchAll() as $row) {
|
||||
$id = $row['id'];
|
||||
|
||||
if (! isset($foreignKeys[$id])) {
|
||||
$foreignKeys[$id] = [
|
||||
'referenced_table' => $row['table'],
|
||||
'update_rule' => $row['on_update'],
|
||||
'delete_rule' => $row['on_delete'],
|
||||
'columns' => [],
|
||||
'referenced_columns' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$foreignKeys[$id]['columns'][$row['seq']] = $row['from'];
|
||||
$foreignKeys[$id]['referenced_columns'][$row['seq']] = $row['to'];
|
||||
}
|
||||
|
||||
// Sort columns by sequence
|
||||
foreach ($foreignKeys as &$fk) {
|
||||
ksort($fk['columns']);
|
||||
ksort($fk['referenced_columns']);
|
||||
$fk['columns'] = array_values($fk['columns']);
|
||||
$fk['referenced_columns'] = array_values($fk['referenced_columns']);
|
||||
}
|
||||
|
||||
return $foreignKeys;
|
||||
}
|
||||
}
|
||||
194
src/Framework/Database/Schema/Comparison/SchemaDifference.php
Normal file
194
src/Framework/Database/Schema/Comparison/SchemaDifference.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Comparison;
|
||||
|
||||
/**
|
||||
* Represents differences between two database schemas
|
||||
*/
|
||||
final readonly class SchemaDifference
|
||||
{
|
||||
/**
|
||||
* Create a new schema difference
|
||||
*
|
||||
* @param string $sourceSchema The source schema name
|
||||
* @param string $targetSchema The target schema name
|
||||
* @param array<string> $missingTables Tables that exist in the source but not in the target
|
||||
* @param array<string> $extraTables Tables that exist in the target but not in the source
|
||||
* @param array<string, TableDifference> $tableDifferences Differences in tables that exist in both schemas
|
||||
*/
|
||||
public function __construct(
|
||||
public string $sourceSchema,
|
||||
public string $targetSchema,
|
||||
public array $missingTables,
|
||||
public array $extraTables,
|
||||
public array $tableDifferences
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any differences between the schemas
|
||||
*/
|
||||
public function hasDifferences(): bool
|
||||
{
|
||||
return ! empty($this->missingTables) || ! empty($this->extraTables) || ! empty($this->tableDifferences);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of the differences
|
||||
*
|
||||
* @return array<string, mixed> Summary of the differences
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
$summary = [
|
||||
'source_schema' => $this->sourceSchema,
|
||||
'target_schema' => $this->targetSchema,
|
||||
'missing_tables' => count($this->missingTables),
|
||||
'extra_tables' => count($this->extraTables),
|
||||
'modified_tables' => count($this->tableDifferences),
|
||||
];
|
||||
|
||||
$columnCounts = [
|
||||
'missing_columns' => 0,
|
||||
'extra_columns' => 0,
|
||||
'modified_columns' => 0,
|
||||
];
|
||||
|
||||
$indexCounts = [
|
||||
'missing_indexes' => 0,
|
||||
'extra_indexes' => 0,
|
||||
'modified_indexes' => 0,
|
||||
];
|
||||
|
||||
$foreignKeyCounts = [
|
||||
'missing_foreign_keys' => 0,
|
||||
'extra_foreign_keys' => 0,
|
||||
'modified_foreign_keys' => 0,
|
||||
];
|
||||
|
||||
foreach ($this->tableDifferences as $tableDifference) {
|
||||
$columnCounts['missing_columns'] += count($tableDifference->missingColumns);
|
||||
$columnCounts['extra_columns'] += count($tableDifference->extraColumns);
|
||||
$columnCounts['modified_columns'] += count($tableDifference->modifiedColumns);
|
||||
|
||||
$indexCounts['missing_indexes'] += count($tableDifference->missingIndexes);
|
||||
$indexCounts['extra_indexes'] += count($tableDifference->extraIndexes);
|
||||
$indexCounts['modified_indexes'] += count($tableDifference->modifiedIndexes);
|
||||
|
||||
$foreignKeyCounts['missing_foreign_keys'] += count($tableDifference->missingForeignKeys);
|
||||
$foreignKeyCounts['extra_foreign_keys'] += count($tableDifference->extraForeignKeys);
|
||||
$foreignKeyCounts['modified_foreign_keys'] += count($tableDifference->modifiedForeignKeys);
|
||||
}
|
||||
|
||||
return array_merge($summary, $columnCounts, $indexCounts, $foreignKeyCounts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a detailed description of the differences
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
$description = [];
|
||||
|
||||
$description[] = "Schema Comparison: {$this->sourceSchema} -> {$this->targetSchema}";
|
||||
$description[] = "";
|
||||
|
||||
if (! $this->hasDifferences()) {
|
||||
$description[] = "No differences found.";
|
||||
|
||||
return implode("\n", $description);
|
||||
}
|
||||
|
||||
if (! empty($this->missingTables)) {
|
||||
$description[] = "Missing Tables:";
|
||||
foreach ($this->missingTables as $table) {
|
||||
$description[] = " - {$table}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->extraTables)) {
|
||||
$description[] = "Extra Tables:";
|
||||
foreach ($this->extraTables as $table) {
|
||||
$description[] = " - {$table}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->tableDifferences)) {
|
||||
$description[] = "Table Differences:";
|
||||
foreach ($this->tableDifferences as $tableName => $tableDifference) {
|
||||
$description[] = " {$tableName}:";
|
||||
|
||||
if (! empty($tableDifference->missingColumns)) {
|
||||
$description[] = " Missing Columns:";
|
||||
foreach ($tableDifference->missingColumns as $columnName => $columnDef) {
|
||||
$description[] = " - {$columnName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->extraColumns)) {
|
||||
$description[] = " Extra Columns:";
|
||||
foreach ($tableDifference->extraColumns as $columnName => $columnDef) {
|
||||
$description[] = " - {$columnName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->modifiedColumns)) {
|
||||
$description[] = " Modified Columns:";
|
||||
foreach ($tableDifference->modifiedColumns as $columnName => $columnDiff) {
|
||||
$description[] = " - {$columnName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->missingIndexes)) {
|
||||
$description[] = " Missing Indexes:";
|
||||
foreach ($tableDifference->missingIndexes as $indexName => $indexDef) {
|
||||
$description[] = " - {$indexName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->extraIndexes)) {
|
||||
$description[] = " Extra Indexes:";
|
||||
foreach ($tableDifference->extraIndexes as $indexName => $indexDef) {
|
||||
$description[] = " - {$indexName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->modifiedIndexes)) {
|
||||
$description[] = " Modified Indexes:";
|
||||
foreach ($tableDifference->modifiedIndexes as $indexName => $indexDiff) {
|
||||
$description[] = " - {$indexName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->missingForeignKeys)) {
|
||||
$description[] = " Missing Foreign Keys:";
|
||||
foreach ($tableDifference->missingForeignKeys as $fkName => $fkDef) {
|
||||
$description[] = " - {$fkName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->extraForeignKeys)) {
|
||||
$description[] = " Extra Foreign Keys:";
|
||||
foreach ($tableDifference->extraForeignKeys as $fkName => $fkDef) {
|
||||
$description[] = " - {$fkName}";
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($tableDifference->modifiedForeignKeys)) {
|
||||
$description[] = " Modified Foreign Keys:";
|
||||
foreach ($tableDifference->modifiedForeignKeys as $fkName => $fkDiff) {
|
||||
$description[] = " - {$fkName}";
|
||||
}
|
||||
}
|
||||
|
||||
$description[] = "";
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $description);
|
||||
}
|
||||
}
|
||||
220
src/Framework/Database/Schema/Comparison/TableDifference.php
Normal file
220
src/Framework/Database/Schema/Comparison/TableDifference.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Comparison;
|
||||
|
||||
/**
|
||||
* Represents differences between two database tables
|
||||
*/
|
||||
final readonly class TableDifference
|
||||
{
|
||||
/**
|
||||
* Create a new table difference
|
||||
*
|
||||
* @param string $tableName The name of the table
|
||||
* @param array<string, array<string, mixed>> $missingColumns Columns that exist in the source but not in the target
|
||||
* @param array<string, array<string, mixed>> $extraColumns Columns that exist in the target but not in the source
|
||||
* @param array<string, array<string, array<string, mixed>>> $modifiedColumns Columns that exist in both but have differences
|
||||
* @param array<string, array<string, mixed>> $missingIndexes Indexes that exist in the source but not in the target
|
||||
* @param array<string, array<string, mixed>> $extraIndexes Indexes that exist in the target but not in the source
|
||||
* @param array<string, array<string, array<string, mixed>>> $modifiedIndexes Indexes that exist in both but have differences
|
||||
* @param array<string, array<string, mixed>> $missingForeignKeys Foreign keys that exist in the source but not in the target
|
||||
* @param array<string, array<string, mixed>> $extraForeignKeys Foreign keys that exist in the target but not in the source
|
||||
* @param array<string, array<string, array<string, mixed>>> $modifiedForeignKeys Foreign keys that exist in both but have differences
|
||||
*/
|
||||
public function __construct(
|
||||
public string $tableName,
|
||||
public array $missingColumns,
|
||||
public array $extraColumns,
|
||||
public array $modifiedColumns,
|
||||
public array $missingIndexes,
|
||||
public array $extraIndexes,
|
||||
public array $modifiedIndexes,
|
||||
public array $missingForeignKeys,
|
||||
public array $extraForeignKeys,
|
||||
public array $modifiedForeignKeys
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any differences in the table
|
||||
*/
|
||||
public function hasDifferences(): bool
|
||||
{
|
||||
return ! empty($this->missingColumns) || ! empty($this->extraColumns) || ! empty($this->modifiedColumns) ||
|
||||
! empty($this->missingIndexes) || ! empty($this->extraIndexes) || ! empty($this->modifiedIndexes) ||
|
||||
! empty($this->missingForeignKeys) || ! empty($this->extraForeignKeys) || ! empty($this->modifiedForeignKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of the differences
|
||||
*
|
||||
* @return array<string, int> Summary of the differences
|
||||
*/
|
||||
public function getSummary(): array
|
||||
{
|
||||
return [
|
||||
'missing_columns' => count($this->missingColumns),
|
||||
'extra_columns' => count($this->extraColumns),
|
||||
'modified_columns' => count($this->modifiedColumns),
|
||||
'missing_indexes' => count($this->missingIndexes),
|
||||
'extra_indexes' => count($this->extraIndexes),
|
||||
'modified_indexes' => count($this->modifiedIndexes),
|
||||
'missing_foreign_keys' => count($this->missingForeignKeys),
|
||||
'extra_foreign_keys' => count($this->extraForeignKeys),
|
||||
'modified_foreign_keys' => count($this->modifiedForeignKeys),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a detailed description of the differences
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
$description = [];
|
||||
|
||||
$description[] = "Table: {$this->tableName}";
|
||||
$description[] = "";
|
||||
|
||||
if (! $this->hasDifferences()) {
|
||||
$description[] = "No differences found.";
|
||||
|
||||
return implode("\n", $description);
|
||||
}
|
||||
|
||||
if (! empty($this->missingColumns)) {
|
||||
$description[] = "Missing Columns:";
|
||||
foreach ($this->missingColumns as $columnName => $columnDef) {
|
||||
$type = $columnDef['type'] ?? 'unknown';
|
||||
$nullable = $columnDef['nullable'] ? 'NULL' : 'NOT NULL';
|
||||
$default = isset($columnDef['default']) ? "DEFAULT {$columnDef['default']}" : '';
|
||||
$description[] = " - {$columnName} {$type} {$nullable} {$default}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->extraColumns)) {
|
||||
$description[] = "Extra Columns:";
|
||||
foreach ($this->extraColumns as $columnName => $columnDef) {
|
||||
$type = $columnDef['type'] ?? 'unknown';
|
||||
$nullable = $columnDef['nullable'] ? 'NULL' : 'NOT NULL';
|
||||
$default = isset($columnDef['default']) ? "DEFAULT {$columnDef['default']}" : '';
|
||||
$description[] = " - {$columnName} {$type} {$nullable} {$default}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->modifiedColumns)) {
|
||||
$description[] = "Modified Columns:";
|
||||
foreach ($this->modifiedColumns as $columnName => $columnDiff) {
|
||||
$sourceType = $columnDiff['source']['type'] ?? 'unknown';
|
||||
$targetType = $columnDiff['target']['type'] ?? 'unknown';
|
||||
$sourceNullable = $columnDiff['source']['nullable'] ? 'NULL' : 'NOT NULL';
|
||||
$targetNullable = $columnDiff['target']['nullable'] ? 'NULL' : 'NOT NULL';
|
||||
$sourceDefault = isset($columnDiff['source']['default']) ? "DEFAULT {$columnDiff['source']['default']}" : '';
|
||||
$targetDefault = isset($columnDiff['target']['default']) ? "DEFAULT {$columnDiff['target']['default']}" : '';
|
||||
|
||||
$description[] = " - {$columnName}:";
|
||||
$description[] = " Source: {$sourceType} {$sourceNullable} {$sourceDefault}";
|
||||
$description[] = " Target: {$targetType} {$targetNullable} {$targetDefault}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->missingIndexes)) {
|
||||
$description[] = "Missing Indexes:";
|
||||
foreach ($this->missingIndexes as $indexName => $indexDef) {
|
||||
$type = $indexDef['type'] ?? 'INDEX';
|
||||
$unique = $indexDef['unique'] ? 'UNIQUE' : '';
|
||||
$columns = implode(', ', $indexDef['columns'] ?? []);
|
||||
$description[] = " - {$indexName} {$unique} {$type} ({$columns})";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->extraIndexes)) {
|
||||
$description[] = "Extra Indexes:";
|
||||
foreach ($this->extraIndexes as $indexName => $indexDef) {
|
||||
$type = $indexDef['type'] ?? 'INDEX';
|
||||
$unique = $indexDef['unique'] ? 'UNIQUE' : '';
|
||||
$columns = implode(', ', $indexDef['columns'] ?? []);
|
||||
$description[] = " - {$indexName} {$unique} {$type} ({$columns})";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->modifiedIndexes)) {
|
||||
$description[] = "Modified Indexes:";
|
||||
foreach ($this->modifiedIndexes as $indexName => $indexDiff) {
|
||||
$sourceType = $indexDiff['source']['type'] ?? 'INDEX';
|
||||
$targetType = $indexDiff['target']['type'] ?? 'INDEX';
|
||||
$sourceUnique = $indexDiff['source']['unique'] ? 'UNIQUE' : '';
|
||||
$targetUnique = $indexDiff['target']['unique'] ? 'UNIQUE' : '';
|
||||
$sourceColumns = implode(', ', $indexDiff['source']['columns'] ?? []);
|
||||
$targetColumns = implode(', ', $indexDiff['target']['columns'] ?? []);
|
||||
|
||||
$description[] = " - {$indexName}:";
|
||||
$description[] = " Source: {$sourceUnique} {$sourceType} ({$sourceColumns})";
|
||||
$description[] = " Target: {$targetUnique} {$targetType} ({$targetColumns})";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->missingForeignKeys)) {
|
||||
$description[] = "Missing Foreign Keys:";
|
||||
foreach ($this->missingForeignKeys as $fkName => $fkDef) {
|
||||
$columns = implode(', ', $fkDef['columns'] ?? []);
|
||||
$refTable = $fkDef['referenced_table'] ?? '';
|
||||
$refColumns = implode(', ', $fkDef['referenced_columns'] ?? []);
|
||||
$updateRule = $fkDef['update_rule'] ?? 'NO ACTION';
|
||||
$deleteRule = $fkDef['delete_rule'] ?? 'NO ACTION';
|
||||
|
||||
$description[] = " - {$fkName}: ({$columns}) REFERENCES {$refTable} ({$refColumns})";
|
||||
$description[] = " ON UPDATE {$updateRule} ON DELETE {$deleteRule}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->extraForeignKeys)) {
|
||||
$description[] = "Extra Foreign Keys:";
|
||||
foreach ($this->extraForeignKeys as $fkName => $fkDef) {
|
||||
$columns = implode(', ', $fkDef['columns'] ?? []);
|
||||
$refTable = $fkDef['referenced_table'] ?? '';
|
||||
$refColumns = implode(', ', $fkDef['referenced_columns'] ?? []);
|
||||
$updateRule = $fkDef['update_rule'] ?? 'NO ACTION';
|
||||
$deleteRule = $fkDef['delete_rule'] ?? 'NO ACTION';
|
||||
|
||||
$description[] = " - {$fkName}: ({$columns}) REFERENCES {$refTable} ({$refColumns})";
|
||||
$description[] = " ON UPDATE {$updateRule} ON DELETE {$deleteRule}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
if (! empty($this->modifiedForeignKeys)) {
|
||||
$description[] = "Modified Foreign Keys:";
|
||||
foreach ($this->modifiedForeignKeys as $fkName => $fkDiff) {
|
||||
$sourceColumns = implode(', ', $fkDiff['source']['columns'] ?? []);
|
||||
$sourceRefTable = $fkDiff['source']['referenced_table'] ?? '';
|
||||
$sourceRefColumns = implode(', ', $fkDiff['source']['referenced_columns'] ?? []);
|
||||
$sourceUpdateRule = $fkDiff['source']['update_rule'] ?? 'NO ACTION';
|
||||
$sourceDeleteRule = $fkDiff['source']['delete_rule'] ?? 'NO ACTION';
|
||||
|
||||
$targetColumns = implode(', ', $fkDiff['target']['columns'] ?? []);
|
||||
$targetRefTable = $fkDiff['target']['referenced_table'] ?? '';
|
||||
$targetRefColumns = implode(', ', $fkDiff['target']['referenced_columns'] ?? []);
|
||||
$targetUpdateRule = $fkDiff['target']['update_rule'] ?? 'NO ACTION';
|
||||
$targetDeleteRule = $fkDiff['target']['delete_rule'] ?? 'NO ACTION';
|
||||
|
||||
$description[] = " - {$fkName}:";
|
||||
$description[] = " Source: ({$sourceColumns}) REFERENCES {$sourceRefTable} ({$sourceRefColumns})";
|
||||
$description[] = " ON UPDATE {$sourceUpdateRule} ON DELETE {$sourceDeleteRule}";
|
||||
$description[] = " Target: ({$targetColumns}) REFERENCES {$targetRefTable} ({$targetRefColumns})";
|
||||
$description[] = " ON UPDATE {$targetUpdateRule} ON DELETE {$targetDeleteRule}";
|
||||
}
|
||||
$description[] = "";
|
||||
}
|
||||
|
||||
return implode("\n", $description);
|
||||
}
|
||||
}
|
||||
14
src/Framework/Database/Schema/ForeignKeyAction.php
Normal file
14
src/Framework/Database/Schema/ForeignKeyAction.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
enum ForeignKeyAction: string
|
||||
{
|
||||
case CASCADE = 'CASCADE';
|
||||
case SET_NULL = 'SET NULL';
|
||||
case RESTRICT = 'RESTRICT';
|
||||
case NO_ACTION = 'NO ACTION';
|
||||
case SET_DEFAULT = 'SET DEFAULT';
|
||||
}
|
||||
80
src/Framework/Database/Schema/ForeignKeyDefinition.php
Normal file
80
src/Framework/Database/Schema/ForeignKeyDefinition.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
final class ForeignKeyDefinition
|
||||
{
|
||||
public readonly array $columns;
|
||||
|
||||
public ?string $referencedTable = null;
|
||||
|
||||
public array $referencedColumns = [];
|
||||
|
||||
public ForeignKeyAction $onUpdate = ForeignKeyAction::RESTRICT;
|
||||
|
||||
public ForeignKeyAction $onDelete = ForeignKeyAction::RESTRICT;
|
||||
|
||||
public ?string $name = null;
|
||||
|
||||
public function __construct(array $columns)
|
||||
{
|
||||
$this->columns = $columns;
|
||||
}
|
||||
|
||||
public function references(string|array $columns): self
|
||||
{
|
||||
$this->referencedColumns = (array) $columns;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function on(string $table): self
|
||||
{
|
||||
$this->referencedTable = $table;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function onUpdate(ForeignKeyAction $action): self
|
||||
{
|
||||
$this->onUpdate = $action;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function onDelete(ForeignKeyAction $action): self
|
||||
{
|
||||
$this->onDelete = $action;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function cascadeOnUpdate(): self
|
||||
{
|
||||
return $this->onUpdate(ForeignKeyAction::CASCADE);
|
||||
}
|
||||
|
||||
public function cascadeOnDelete(): self
|
||||
{
|
||||
return $this->onDelete(ForeignKeyAction::CASCADE);
|
||||
}
|
||||
|
||||
public function nullOnDelete(): self
|
||||
{
|
||||
return $this->onDelete(ForeignKeyAction::SET_NULL);
|
||||
}
|
||||
|
||||
public function restrictOnDelete(): self
|
||||
{
|
||||
return $this->onDelete(ForeignKeyAction::RESTRICT);
|
||||
}
|
||||
|
||||
public function name(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
380
src/Framework/Database/Schema/Index/AdvancedIndexDefinition.php
Normal file
380
src/Framework/Database/Schema/Index/AdvancedIndexDefinition.php
Normal file
@@ -0,0 +1,380 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Index;
|
||||
|
||||
/**
|
||||
* Advanced index definition with support for partial indexes, functional indexes,
|
||||
* and database-specific options
|
||||
*/
|
||||
final class AdvancedIndexDefinition
|
||||
{
|
||||
/**
|
||||
* @var string|null The name of the index
|
||||
*/
|
||||
public ?string $name;
|
||||
|
||||
/**
|
||||
* @var array<string> The columns to index
|
||||
*/
|
||||
public array $columns;
|
||||
|
||||
/**
|
||||
* @var AdvancedIndexType The type of index
|
||||
*/
|
||||
public AdvancedIndexType $type;
|
||||
|
||||
/**
|
||||
* @var string|null WHERE clause for partial indexes
|
||||
*/
|
||||
public ?string $whereClause;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed> Database-specific options
|
||||
*/
|
||||
public array $options;
|
||||
|
||||
/**
|
||||
* @var bool Whether this is a functional index
|
||||
*/
|
||||
public bool $isFunctional;
|
||||
|
||||
/**
|
||||
* @var array<string> Expressions for functional indexes
|
||||
*/
|
||||
public array $expressions;
|
||||
|
||||
/**
|
||||
* Create a new advanced index definition
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param AdvancedIndexType $type The type of index
|
||||
* @param string|null $whereClause WHERE clause for partial indexes
|
||||
* @param array<string, mixed> $options Database-specific options
|
||||
* @param bool $isFunctional Whether this is a functional index
|
||||
* @param array<string> $expressions Expressions for functional indexes
|
||||
*/
|
||||
public function __construct(
|
||||
?string $name,
|
||||
array $columns,
|
||||
AdvancedIndexType $type,
|
||||
?string $whereClause = null,
|
||||
array $options = [],
|
||||
bool $isFunctional = false,
|
||||
array $expressions = []
|
||||
) {
|
||||
$this->name = $name;
|
||||
$this->columns = $columns;
|
||||
$this->type = $type;
|
||||
$this->whereClause = $whereClause;
|
||||
$this->options = $options;
|
||||
$this->isFunctional = $isFunctional;
|
||||
$this->expressions = $expressions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standard index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param AdvancedIndexType $type The type of index
|
||||
* @return self
|
||||
*/
|
||||
public static function create(?string $name, array $columns, AdvancedIndexType $type): self
|
||||
{
|
||||
return new self($name, $columns, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a partial index with a WHERE clause
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param AdvancedIndexType $type The type of index
|
||||
* @param string $whereClause WHERE clause for the partial index
|
||||
* @return self
|
||||
*/
|
||||
public static function partial(?string $name, array $columns, AdvancedIndexType $type, string $whereClause): self
|
||||
{
|
||||
return new self($name, $columns, $type, $whereClause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a functional index with expressions
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param AdvancedIndexType $type The type of index
|
||||
* @param array<string> $expressions SQL expressions for the functional index
|
||||
* @return self
|
||||
*/
|
||||
public static function functional(?string $name, AdvancedIndexType $type, array $expressions): self
|
||||
{
|
||||
return new self($name, [], $type, null, [], true, $expressions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PostgreSQL GIN index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param array<string, mixed> $options Additional options
|
||||
* @return self
|
||||
*/
|
||||
public static function gin(?string $name, array $columns, array $options = []): self
|
||||
{
|
||||
return new self($name, $columns, AdvancedIndexType::GIN, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PostgreSQL GiST index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param array<string, mixed> $options Additional options
|
||||
* @return self
|
||||
*/
|
||||
public static function gist(?string $name, array $columns, array $options = []): self
|
||||
{
|
||||
return new self($name, $columns, AdvancedIndexType::GIST, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PostgreSQL BRIN index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param array<string, mixed> $options Additional options
|
||||
* @return self
|
||||
*/
|
||||
public static function brin(?string $name, array $columns, array $options = []): self
|
||||
{
|
||||
return new self($name, $columns, AdvancedIndexType::BRIN, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PostgreSQL SP-GiST index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param array<string, mixed> $options Additional options
|
||||
* @return self
|
||||
*/
|
||||
public static function spgist(?string $name, array $columns, array $options = []): self
|
||||
{
|
||||
return new self($name, $columns, AdvancedIndexType::SPGIST, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MySQL BTREE index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param array<string, mixed> $options Additional options
|
||||
* @return self
|
||||
*/
|
||||
public static function btree(?string $name, array $columns, array $options = []): self
|
||||
{
|
||||
return new self($name, $columns, AdvancedIndexType::BTREE, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MySQL RTREE index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param array<string, mixed> $options Additional options
|
||||
* @return self
|
||||
*/
|
||||
public static function rtree(?string $name, array $columns, array $options = []): self
|
||||
{
|
||||
return new self($name, $columns, AdvancedIndexType::RTREE, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MySQL HASH index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param array<string, mixed> $options Additional options
|
||||
* @return self
|
||||
*/
|
||||
public static function hash(?string $name, array $columns, array $options = []): self
|
||||
{
|
||||
return new self($name, $columns, AdvancedIndexType::HASH_MYSQL, null, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for this index
|
||||
*
|
||||
* @param string $driver The database driver (mysql, pgsql, sqlite)
|
||||
* @param string $table The table name
|
||||
* @return string The SQL statement
|
||||
*/
|
||||
public function toSql(string $driver, string $table): string
|
||||
{
|
||||
// Check if this index type is supported by the driver
|
||||
if (! $this->type->isSupportedBy($driver)) {
|
||||
throw new \InvalidArgumentException(
|
||||
"Index type {$this->type->value} is not supported by {$driver}"
|
||||
);
|
||||
}
|
||||
|
||||
// Handle functional indexes
|
||||
if ($this->isFunctional) {
|
||||
return $this->functionalIndexToSql($driver, $table);
|
||||
}
|
||||
|
||||
// Handle partial indexes
|
||||
if ($this->whereClause !== null) {
|
||||
return $this->partialIndexToSql($driver, $table);
|
||||
}
|
||||
|
||||
// Handle standard indexes
|
||||
return $this->standardIndexToSql($driver, $table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for a standard index
|
||||
*/
|
||||
private function standardIndexToSql(string $driver, string $table): string
|
||||
{
|
||||
$indexType = $this->type->toSql($driver);
|
||||
$indexName = $this->name ?? $this->generateIndexName($table);
|
||||
$columns = $this->formatColumns($driver);
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $this->toMySqlIndex($indexType, $indexName, $table, $columns),
|
||||
'pgsql' => $this->toPostgreSqlIndex($indexType, $indexName, $table, $columns),
|
||||
'sqlite' => $this->toSqliteIndex($indexType, $indexName, $table, $columns),
|
||||
default => throw new \InvalidArgumentException("Unsupported driver: {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for a partial index
|
||||
*/
|
||||
private function partialIndexToSql(string $driver, string $table): string
|
||||
{
|
||||
$indexType = $this->type->toSql($driver);
|
||||
$indexName = $this->name ?? $this->generateIndexName($table, 'partial');
|
||||
$columns = $this->formatColumns($driver);
|
||||
$whereClause = $this->whereClause;
|
||||
|
||||
return match($driver) {
|
||||
'pgsql' => "CREATE {$indexType} INDEX \"{$indexName}\" ON \"{$table}\" ({$columns}) WHERE {$whereClause}",
|
||||
'sqlite' => "CREATE {$indexType} INDEX \"{$indexName}\" ON \"{$table}\" ({$columns}) WHERE {$whereClause}",
|
||||
'mysql' => throw new \InvalidArgumentException("MySQL does not support partial indexes"),
|
||||
default => throw new \InvalidArgumentException("Unsupported driver: {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for a functional index
|
||||
*/
|
||||
private function functionalIndexToSql(string $driver, string $table): string
|
||||
{
|
||||
$indexType = $this->type->toSql($driver);
|
||||
$indexName = $this->name ?? $this->generateIndexName($table, 'func');
|
||||
$expressions = implode(', ', $this->expressions);
|
||||
|
||||
return match($driver) {
|
||||
'pgsql' => "CREATE {$indexType} INDEX \"{$indexName}\" ON \"{$table}\" ({$expressions})",
|
||||
'mysql' => "ALTER TABLE `{$table}` ADD {$indexType} `{$indexName}` ({$expressions})",
|
||||
'sqlite' => throw new \InvalidArgumentException("SQLite does not support functional indexes"),
|
||||
default => throw new \InvalidArgumentException("Unsupported driver: {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MySQL-specific index SQL
|
||||
*/
|
||||
private function toMySqlIndex(string $indexType, string $indexName, string $table, string $columns): string
|
||||
{
|
||||
// For PRIMARY KEY, we use ALTER TABLE
|
||||
if ($this->type === AdvancedIndexType::PRIMARY) {
|
||||
return "ALTER TABLE `{$table}` ADD PRIMARY KEY ({$columns})";
|
||||
}
|
||||
|
||||
// For other index types, we can use CREATE INDEX
|
||||
$using = '';
|
||||
if ($this->type === AdvancedIndexType::BTREE || $this->type === AdvancedIndexType::HASH_MYSQL || $this->type === AdvancedIndexType::RTREE) {
|
||||
$using = " USING {$this->type->value}";
|
||||
}
|
||||
|
||||
return "CREATE {$indexType} INDEX `{$indexName}` ON `{$table}` ({$columns}){$using}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PostgreSQL-specific index SQL
|
||||
*/
|
||||
private function toPostgreSqlIndex(string $indexType, string $indexName, string $table, string $columns): string
|
||||
{
|
||||
// For PRIMARY KEY, we use ALTER TABLE
|
||||
if ($this->type === AdvancedIndexType::PRIMARY) {
|
||||
return "ALTER TABLE \"{$table}\" ADD PRIMARY KEY ({$columns})";
|
||||
}
|
||||
|
||||
// For other index types, we can use CREATE INDEX
|
||||
$using = '';
|
||||
if (in_array($this->type, [
|
||||
AdvancedIndexType::GIN,
|
||||
AdvancedIndexType::GIST,
|
||||
AdvancedIndexType::BRIN,
|
||||
AdvancedIndexType::HASH,
|
||||
AdvancedIndexType::SPGIST,
|
||||
])) {
|
||||
$using = " USING {$this->type->value}";
|
||||
}
|
||||
|
||||
return "CREATE {$indexType} INDEX \"{$indexName}\" ON \"{$table}\"{$using} ({$columns})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQLite-specific index SQL
|
||||
*/
|
||||
private function toSqliteIndex(string $indexType, string $indexName, string $table, string $columns): string
|
||||
{
|
||||
// For PRIMARY KEY, we need to define it in the CREATE TABLE statement
|
||||
// This is just a placeholder, as SQLite PRIMARY KEYs are defined in the table schema
|
||||
if ($this->type === AdvancedIndexType::PRIMARY) {
|
||||
return "-- PRIMARY KEY for SQLite must be defined in CREATE TABLE statement";
|
||||
}
|
||||
|
||||
return "CREATE {$indexType} INDEX \"{$indexName}\" ON \"{$table}\" ({$columns})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format columns for SQL
|
||||
*/
|
||||
private function formatColumns(string $driver): string
|
||||
{
|
||||
$columns = $this->columns;
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => '`' . implode('`, `', $columns) . '`',
|
||||
'pgsql' => '"' . implode('", "', $columns) . '"',
|
||||
'sqlite' => '"' . implode('", "', $columns) . '"',
|
||||
default => implode(', ', $columns)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a default index name
|
||||
*/
|
||||
private function generateIndexName(string $table, string $suffix = ''): string
|
||||
{
|
||||
$name = $table . '_' . implode('_', $this->columns);
|
||||
if ($suffix) {
|
||||
$name .= '_' . $suffix;
|
||||
}
|
||||
|
||||
// Ensure the name is not too long
|
||||
if (strlen($name) > 63) {
|
||||
$name = substr($name, 0, 60) . '_' . substr(md5($name), 0, 3);
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
159
src/Framework/Database/Schema/Index/AdvancedIndexType.php
Normal file
159
src/Framework/Database/Schema/Index/AdvancedIndexType.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Index;
|
||||
|
||||
/**
|
||||
* Advanced index types including database-specific types
|
||||
*/
|
||||
enum AdvancedIndexType: string
|
||||
{
|
||||
// Standard index types (matching the basic IndexType enum)
|
||||
case PRIMARY = 'primary';
|
||||
case UNIQUE = 'unique';
|
||||
case INDEX = 'index';
|
||||
case FULLTEXT = 'fulltext';
|
||||
case SPATIAL = 'spatial';
|
||||
|
||||
// PostgreSQL-specific index types
|
||||
case GIN = 'gin'; // Generalized Inverted Index - for arrays, JSON, full-text search
|
||||
case GIST = 'gist'; // Generalized Search Tree - for geometric data, ranges, etc.
|
||||
case BRIN = 'brin'; // Block Range Index - for large tables with natural ordering
|
||||
case HASH = 'hash'; // Hash index - for equality comparisons
|
||||
case SPGIST = 'spgist'; // Space-partitioned GiST - for clustered data
|
||||
|
||||
// MySQL-specific index types
|
||||
case BTREE = 'btree'; // B-tree index (default in MySQL)
|
||||
case RTREE = 'rtree'; // R-tree index for spatial data
|
||||
case HASH_MYSQL = 'hash_mysql'; // Hash index in MySQL (different from PostgreSQL's hash)
|
||||
|
||||
// Partial and functional index types
|
||||
case PARTIAL = 'partial'; // Index with WHERE clause
|
||||
case FUNCTIONAL = 'functional'; // Index on expressions/functions
|
||||
|
||||
/**
|
||||
* Check if this index type is supported by the given database driver
|
||||
*/
|
||||
public function isSupportedBy(string $driver): bool
|
||||
{
|
||||
return match($this) {
|
||||
// Standard types supported by all databases
|
||||
self::PRIMARY, self::UNIQUE, self::INDEX => true,
|
||||
|
||||
// Full-text indexes
|
||||
self::FULLTEXT => in_array($driver, ['mysql', 'pgsql']),
|
||||
|
||||
// Spatial indexes
|
||||
self::SPATIAL => in_array($driver, ['mysql', 'pgsql']),
|
||||
|
||||
// PostgreSQL-specific types
|
||||
self::GIN, self::GIST, self::BRIN, self::HASH, self::SPGIST => $driver === 'pgsql',
|
||||
|
||||
// MySQL-specific types
|
||||
self::BTREE, self::RTREE, self::HASH_MYSQL => $driver === 'mysql',
|
||||
|
||||
// Partial indexes
|
||||
self::PARTIAL => in_array($driver, ['pgsql', 'sqlite']),
|
||||
|
||||
// Functional indexes
|
||||
self::FUNCTIONAL => in_array($driver, ['pgsql', 'mysql']),
|
||||
|
||||
default => false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SQL keyword for this index type
|
||||
*/
|
||||
public function toSql(string $driver): string
|
||||
{
|
||||
return match($this) {
|
||||
// Standard types
|
||||
self::PRIMARY => 'PRIMARY KEY',
|
||||
self::UNIQUE => 'UNIQUE',
|
||||
self::INDEX => match($driver) {
|
||||
'mysql' => 'INDEX',
|
||||
'pgsql' => 'INDEX',
|
||||
'sqlite' => 'INDEX',
|
||||
default => 'INDEX'
|
||||
},
|
||||
self::FULLTEXT => match($driver) {
|
||||
'mysql' => 'FULLTEXT',
|
||||
'pgsql' => 'FULLTEXT', // Using GIN with tsvector in PostgreSQL
|
||||
default => throw new \InvalidArgumentException("FULLTEXT index not supported by {$driver}")
|
||||
},
|
||||
self::SPATIAL => match($driver) {
|
||||
'mysql' => 'SPATIAL',
|
||||
'pgsql' => 'SPATIAL', // Using GiST with geometry in PostgreSQL
|
||||
default => throw new \InvalidArgumentException("SPATIAL index not supported by {$driver}")
|
||||
},
|
||||
|
||||
// PostgreSQL-specific types
|
||||
self::GIN => match($driver) {
|
||||
'pgsql' => 'GIN',
|
||||
default => throw new \InvalidArgumentException("GIN index not supported by {$driver}")
|
||||
},
|
||||
self::GIST => match($driver) {
|
||||
'pgsql' => 'GIST',
|
||||
default => throw new \InvalidArgumentException("GIST index not supported by {$driver}")
|
||||
},
|
||||
self::BRIN => match($driver) {
|
||||
'pgsql' => 'BRIN',
|
||||
default => throw new \InvalidArgumentException("BRIN index not supported by {$driver}")
|
||||
},
|
||||
self::HASH => match($driver) {
|
||||
'pgsql' => 'HASH',
|
||||
default => throw new \InvalidArgumentException("HASH index not supported by {$driver}")
|
||||
},
|
||||
self::SPGIST => match($driver) {
|
||||
'pgsql' => 'SPGIST',
|
||||
default => throw new \InvalidArgumentException("SPGIST index not supported by {$driver}")
|
||||
},
|
||||
|
||||
// MySQL-specific types
|
||||
self::BTREE => match($driver) {
|
||||
'mysql' => 'BTREE',
|
||||
default => throw new \InvalidArgumentException("BTREE index not supported by {$driver}")
|
||||
},
|
||||
self::RTREE => match($driver) {
|
||||
'mysql' => 'RTREE',
|
||||
default => throw new \InvalidArgumentException("RTREE index not supported by {$driver}")
|
||||
},
|
||||
self::HASH_MYSQL => match($driver) {
|
||||
'mysql' => 'HASH',
|
||||
default => throw new \InvalidArgumentException("HASH index not supported by {$driver}")
|
||||
},
|
||||
|
||||
// Partial and functional indexes don't have specific SQL keywords
|
||||
self::PARTIAL, self::FUNCTIONAL => '',
|
||||
|
||||
default => throw new \InvalidArgumentException("Unknown index type: {$this->value}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a description of this index type
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::PRIMARY => 'Primary key index',
|
||||
self::UNIQUE => 'Unique index',
|
||||
self::INDEX => 'Standard index',
|
||||
self::FULLTEXT => 'Full-text search index',
|
||||
self::SPATIAL => 'Spatial index for geographic data',
|
||||
self::GIN => 'Generalized Inverted Index (PostgreSQL)',
|
||||
self::GIST => 'Generalized Search Tree (PostgreSQL)',
|
||||
self::BRIN => 'Block Range Index (PostgreSQL)',
|
||||
self::HASH => 'Hash index (PostgreSQL)',
|
||||
self::SPGIST => 'Space-partitioned GiST (PostgreSQL)',
|
||||
self::BTREE => 'B-tree index (MySQL)',
|
||||
self::RTREE => 'R-tree index (MySQL)',
|
||||
self::HASH_MYSQL => 'Hash index (MySQL)',
|
||||
self::PARTIAL => 'Partial index with WHERE clause',
|
||||
self::FUNCTIONAL => 'Functional index on expressions',
|
||||
default => 'Unknown index type'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Index\Analysis;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
use App\Framework\Database\Profiling\QueryLogger;
|
||||
use App\Framework\Database\Schema\Index\AdvancedIndexType;
|
||||
use App\Framework\Database\Schema\Index\MySQLIndex;
|
||||
use App\Framework\Database\Schema\Index\PostgreSQLIndex;
|
||||
use App\Framework\Database\Schema\Index\SQLiteIndex;
|
||||
|
||||
/**
|
||||
* Recommends indexes based on query patterns
|
||||
*/
|
||||
final class IndexRecommender
|
||||
{
|
||||
/**
|
||||
* @var ConnectionInterface The database connection
|
||||
*/
|
||||
private ConnectionInterface $connection;
|
||||
|
||||
/**
|
||||
* @var QueryLogger|null The query logger for analyzing query patterns
|
||||
*/
|
||||
private ?QueryLogger $queryLogger;
|
||||
|
||||
/**
|
||||
* Create a new index recommender
|
||||
*/
|
||||
public function __construct(ConnectionInterface $connection, ?QueryLogger $queryLogger = null)
|
||||
{
|
||||
$this->connection = $connection;
|
||||
$this->queryLogger = $queryLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get index recommendations based on query patterns
|
||||
*
|
||||
* @param string|null $table Optional table name to filter results
|
||||
* @param int $minQueryCount Minimum number of times a query pattern must be seen to generate a recommendation
|
||||
* @param float $minExecutionTime Minimum execution time (in seconds) for a query to be considered slow
|
||||
* @return array<array<string, mixed>> Index recommendations
|
||||
* @throws DatabaseException If the database doesn't support index recommendations
|
||||
*/
|
||||
public function getRecommendations(
|
||||
?string $table = null,
|
||||
int $minQueryCount = 10,
|
||||
float $minExecutionTime = 0.1
|
||||
): array {
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
// If we have a query logger, use it to analyze query patterns
|
||||
if ($this->queryLogger !== null) {
|
||||
return $this->getRecommendationsFromQueryLogger($driver, $table, $minQueryCount, $minExecutionTime);
|
||||
}
|
||||
|
||||
// Otherwise, use database-specific methods
|
||||
return match($driver) {
|
||||
'mysql' => $this->getMySqlRecommendations($table),
|
||||
'pgsql' => $this->getPostgreSqlRecommendations($table),
|
||||
default => throw new DatabaseException("Index recommendations not supported for {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for creating recommended indexes
|
||||
*
|
||||
* @param array<array<string, mixed>> $recommendations The index recommendations
|
||||
* @return array<string> SQL statements for creating the recommended indexes
|
||||
*/
|
||||
public function generateSql(array $recommendations): array
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
$statements = [];
|
||||
|
||||
foreach ($recommendations as $recommendation) {
|
||||
if (! isset($recommendation['table'], $recommendation['columns'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$table = $recommendation['table'];
|
||||
$columns = $recommendation['columns'];
|
||||
$name = $recommendation['name'] ?? null;
|
||||
$type = $recommendation['type'] ?? 'index';
|
||||
$whereClause = $recommendation['where_clause'] ?? null;
|
||||
|
||||
// Convert type string to AdvancedIndexType
|
||||
$indexType = match($type) {
|
||||
'primary' => AdvancedIndexType::PRIMARY,
|
||||
'unique' => AdvancedIndexType::UNIQUE,
|
||||
'fulltext' => AdvancedIndexType::FULLTEXT,
|
||||
'spatial' => AdvancedIndexType::SPATIAL,
|
||||
'gin' => AdvancedIndexType::GIN,
|
||||
'gist' => AdvancedIndexType::GIST,
|
||||
'brin' => AdvancedIndexType::BRIN,
|
||||
'hash' => AdvancedIndexType::HASH,
|
||||
'btree' => AdvancedIndexType::BTREE,
|
||||
'rtree' => AdvancedIndexType::RTREE,
|
||||
default => AdvancedIndexType::INDEX
|
||||
};
|
||||
|
||||
// Create the appropriate index definition based on the driver
|
||||
$sql = match($driver) {
|
||||
'mysql' => $this->generateMySqlIndexSql($table, $columns, $name, $indexType, $whereClause),
|
||||
'pgsql' => $this->generatePostgreSqlIndexSql($table, $columns, $name, $indexType, $whereClause),
|
||||
'sqlite' => $this->generateSqliteIndexSql($table, $columns, $name, $indexType, $whereClause),
|
||||
default => throw new DatabaseException("Index generation not supported for {$driver}")
|
||||
};
|
||||
|
||||
$statements[] = $sql;
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a specific query and recommend indexes
|
||||
*
|
||||
* @param string $sql The SQL query to analyze
|
||||
* @param array<mixed> $params The query parameters
|
||||
* @return array<array<string, mixed>> Index recommendations
|
||||
* @throws DatabaseException If the database doesn't support query analysis
|
||||
*/
|
||||
public function analyzeQuery(string $sql, array $params = []): array
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $this->analyzeMySqlQuery($sql, $params),
|
||||
'pgsql' => $this->analyzePostgreSqlQuery($sql, $params),
|
||||
default => throw new DatabaseException("Query analysis not supported for {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommendations from the query logger
|
||||
*/
|
||||
private function getRecommendationsFromQueryLogger(
|
||||
string $driver,
|
||||
?string $table = null,
|
||||
int $minQueryCount = 10,
|
||||
float $minExecutionTime = 0.1
|
||||
): array {
|
||||
if ($this->queryLogger === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$recommendations = [];
|
||||
$queryStats = $this->queryLogger->getQueryStatistics();
|
||||
|
||||
foreach ($queryStats as $pattern => $stats) {
|
||||
// Skip queries that don't meet the minimum criteria
|
||||
if ($stats['count'] < $minQueryCount || $stats['avg_time'] < $minExecutionTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip non-SELECT queries
|
||||
if (! preg_match('/^\s*SELECT/i', $pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If a table filter is specified, skip queries that don't involve that table
|
||||
if ($table !== null && ! preg_match('/\bFROM\s+`?' . preg_quote($table, '/') . '`?\b/i', $pattern)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Analyze the query pattern and extract potential index recommendations
|
||||
$queryRecommendations = $this->analyzeQueryPattern($driver, $pattern, $stats);
|
||||
|
||||
foreach ($queryRecommendations as $recommendation) {
|
||||
// Skip recommendations for tables other than the specified one
|
||||
if ($table !== null && $recommendation['table'] !== $table) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recommendations[] = $recommendation;
|
||||
}
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a query pattern and extract potential index recommendations
|
||||
*/
|
||||
private function analyzeQueryPattern(string $driver, string $pattern, array $stats): array
|
||||
{
|
||||
$recommendations = [];
|
||||
|
||||
// Extract tables and conditions from the query pattern
|
||||
$tables = $this->extractTablesFromQuery($pattern);
|
||||
$conditions = $this->extractConditionsFromQuery($pattern);
|
||||
$joins = $this->extractJoinsFromQuery($pattern);
|
||||
$orderBy = $this->extractOrderByFromQuery($pattern);
|
||||
$groupBy = $this->extractGroupByFromQuery($pattern);
|
||||
|
||||
// Generate recommendations for WHERE conditions
|
||||
foreach ($conditions as $table => $tableConditions) {
|
||||
$columns = array_keys($tableConditions);
|
||||
|
||||
if (empty($columns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if the table is not in the FROM clause
|
||||
if (! in_array($table, $tables)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recommendations[] = [
|
||||
'table' => $table,
|
||||
'columns' => $columns,
|
||||
'name' => $this->generateIndexName($table, $columns),
|
||||
'type' => 'index',
|
||||
'reason' => 'WHERE clause conditions',
|
||||
'query_count' => $stats['count'],
|
||||
'avg_time' => $stats['avg_time'],
|
||||
'total_time' => $stats['total_time'],
|
||||
];
|
||||
}
|
||||
|
||||
// Generate recommendations for JOIN conditions
|
||||
foreach ($joins as $joinInfo) {
|
||||
$leftTable = $joinInfo['left_table'];
|
||||
$rightTable = $joinInfo['right_table'];
|
||||
$leftColumn = $joinInfo['left_column'];
|
||||
$rightColumn = $joinInfo['right_column'];
|
||||
|
||||
// Recommend index on the right table's join column
|
||||
$recommendations[] = [
|
||||
'table' => $rightTable,
|
||||
'columns' => [$rightColumn],
|
||||
'name' => $this->generateIndexName($rightTable, [$rightColumn]),
|
||||
'type' => 'index',
|
||||
'reason' => 'JOIN condition',
|
||||
'query_count' => $stats['count'],
|
||||
'avg_time' => $stats['avg_time'],
|
||||
'total_time' => $stats['total_time'],
|
||||
];
|
||||
|
||||
// If the left table is not the main table, also recommend an index on it
|
||||
if (! in_array($leftTable, $tables)) {
|
||||
$recommendations[] = [
|
||||
'table' => $leftTable,
|
||||
'columns' => [$leftColumn],
|
||||
'name' => $this->generateIndexName($leftTable, [$leftColumn]),
|
||||
'type' => 'index',
|
||||
'reason' => 'JOIN condition',
|
||||
'query_count' => $stats['count'],
|
||||
'avg_time' => $stats['avg_time'],
|
||||
'total_time' => $stats['total_time'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate recommendations for ORDER BY clauses
|
||||
foreach ($orderBy as $table => $columns) {
|
||||
if (empty($columns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recommendations[] = [
|
||||
'table' => $table,
|
||||
'columns' => $columns,
|
||||
'name' => $this->generateIndexName($table, $columns, 'order'),
|
||||
'type' => 'index',
|
||||
'reason' => 'ORDER BY clause',
|
||||
'query_count' => $stats['count'],
|
||||
'avg_time' => $stats['avg_time'],
|
||||
'total_time' => $stats['total_time'],
|
||||
];
|
||||
}
|
||||
|
||||
// Generate recommendations for GROUP BY clauses
|
||||
foreach ($groupBy as $table => $columns) {
|
||||
if (empty($columns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$recommendations[] = [
|
||||
'table' => $table,
|
||||
'columns' => $columns,
|
||||
'name' => $this->generateIndexName($table, $columns, 'group'),
|
||||
'type' => 'index',
|
||||
'reason' => 'GROUP BY clause',
|
||||
'query_count' => $stats['count'],
|
||||
'avg_time' => $stats['avg_time'],
|
||||
'total_time' => $stats['total_time'],
|
||||
];
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tables from a query
|
||||
*/
|
||||
private function extractTablesFromQuery(string $query): array
|
||||
{
|
||||
$tables = [];
|
||||
|
||||
// Extract tables from FROM clause
|
||||
if (preg_match('/\bFROM\s+`?([a-zA-Z0-9_]+)`?/i', $query, $matches)) {
|
||||
$tables[] = $matches[1];
|
||||
}
|
||||
|
||||
return $tables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract conditions from a query
|
||||
*/
|
||||
private function extractConditionsFromQuery(string $query): array
|
||||
{
|
||||
$conditions = [];
|
||||
|
||||
// Extract conditions from WHERE clause
|
||||
if (preg_match('/\bWHERE\s+(.*?)(?:\bGROUP BY|\bORDER BY|\bLIMIT|\bFOR UPDATE|\bLOCK IN|\bUNION|\bINTERSECT|\bEXCEPT|\bMINUS|\z)/is', $query, $matches)) {
|
||||
$whereClause = $matches[1];
|
||||
|
||||
// Extract column names from conditions
|
||||
preg_match_all('/\b([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\s*(?:=|<>|!=|>|<|>=|<=|LIKE|IN|NOT IN|BETWEEN|IS NULL|IS NOT NULL)/i', $whereClause, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$table = $match[1];
|
||||
$column = $match[2];
|
||||
|
||||
if (! isset($conditions[$table])) {
|
||||
$conditions[$table] = [];
|
||||
}
|
||||
|
||||
$conditions[$table][$column] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract joins from a query
|
||||
*/
|
||||
private function extractJoinsFromQuery(string $query): array
|
||||
{
|
||||
$joins = [];
|
||||
|
||||
// Extract JOIN conditions
|
||||
preg_match_all('/\b(?:INNER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\s+`?([a-zA-Z0-9_]+)`?\s+(?:AS\s+`?([a-zA-Z0-9_]+)`?)?\s+ON\s+`?([a-zA-Z0-9_]+)`?\.`?([a-zA-Z0-9_]+)`?\s*=\s*`?([a-zA-Z0-9_]+)`?\.`?([a-zA-Z0-9_]+)`?/i', $query, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$rightTable = $match[1];
|
||||
$rightAlias = $match[2] ?: $rightTable;
|
||||
$leftTable = $match[3];
|
||||
$leftColumn = $match[4];
|
||||
$rightTableInCondition = $match[5];
|
||||
$rightColumn = $match[6];
|
||||
|
||||
// Ensure the right table in the condition matches the right table or its alias
|
||||
if ($rightTableInCondition === $rightAlias) {
|
||||
$joins[] = [
|
||||
'left_table' => $leftTable,
|
||||
'left_column' => $leftColumn,
|
||||
'right_table' => $rightTable,
|
||||
'right_column' => $rightColumn,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $joins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ORDER BY columns from a query
|
||||
*/
|
||||
private function extractOrderByFromQuery(string $query): array
|
||||
{
|
||||
$orderByColumns = [];
|
||||
|
||||
// Extract ORDER BY clause
|
||||
if (preg_match('/\bORDER BY\s+(.*?)(?:\bLIMIT|\bFOR UPDATE|\bLOCK IN|\z)/is', $query, $matches)) {
|
||||
$orderByClause = $matches[1];
|
||||
|
||||
// Extract column names from ORDER BY
|
||||
preg_match_all('/\b([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)(?:\s+(?:ASC|DESC))?/i', $orderByClause, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$table = $match[1];
|
||||
$column = $match[2];
|
||||
|
||||
if (! isset($orderByColumns[$table])) {
|
||||
$orderByColumns[$table] = [];
|
||||
}
|
||||
|
||||
$orderByColumns[$table][] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
return $orderByColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract GROUP BY columns from a query
|
||||
*/
|
||||
private function extractGroupByFromQuery(string $query): array
|
||||
{
|
||||
$groupByColumns = [];
|
||||
|
||||
// Extract GROUP BY clause
|
||||
if (preg_match('/\bGROUP BY\s+(.*?)(?:\bHAVING|\bORDER BY|\bLIMIT|\bFOR UPDATE|\bLOCK IN|\z)/is', $query, $matches)) {
|
||||
$groupByClause = $matches[1];
|
||||
|
||||
// Extract column names from GROUP BY
|
||||
preg_match_all('/\b([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)/i', $groupByClause, $matches, PREG_SET_ORDER);
|
||||
|
||||
foreach ($matches as $match) {
|
||||
$table = $match[1];
|
||||
$column = $match[2];
|
||||
|
||||
if (! isset($groupByColumns[$table])) {
|
||||
$groupByColumns[$table] = [];
|
||||
}
|
||||
|
||||
$groupByColumns[$table][] = $column;
|
||||
}
|
||||
}
|
||||
|
||||
return $groupByColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a name for an index
|
||||
*/
|
||||
private function generateIndexName(string $table, array $columns, string $suffix = ''): string
|
||||
{
|
||||
$name = 'idx_' . $table . '_' . implode('_', $columns);
|
||||
|
||||
if ($suffix) {
|
||||
$name .= '_' . $suffix;
|
||||
}
|
||||
|
||||
// Ensure the name is not too long
|
||||
if (strlen($name) > 63) {
|
||||
$name = substr($name, 0, 60) . '_' . substr(md5($name), 0, 3);
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MySQL index recommendations
|
||||
*/
|
||||
private function getMySqlRecommendations(?string $table = null): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'AND t.TABLE_NAME = ?';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
// Use MySQL's sys schema to get index recommendations
|
||||
try {
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
table_schema,
|
||||
table_name,
|
||||
index_columns AS columns,
|
||||
'index' AS type,
|
||||
NULL AS name,
|
||||
rows_examined,
|
||||
rows_examined / executions AS avg_rows_examined,
|
||||
executions AS query_count,
|
||||
query_sample AS sample_query
|
||||
FROM
|
||||
sys.schema_tables_with_full_table_scans
|
||||
WHERE
|
||||
table_schema NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')
|
||||
{$whereClause}
|
||||
ORDER BY
|
||||
rows_examined DESC
|
||||
SQL;
|
||||
|
||||
$recommendations = $this->connection->query($sql, $params)->fetchAll();
|
||||
|
||||
// Process the recommendations to extract column names
|
||||
foreach ($recommendations as &$recommendation) {
|
||||
if (isset($recommendation['columns'])) {
|
||||
// Extract column names from the sample query
|
||||
$columns = $this->extractColumnsFromSampleQuery($recommendation['sample_query'], $recommendation['table_name']);
|
||||
$recommendation['columns'] = $columns;
|
||||
$recommendation['name'] = $this->generateIndexName($recommendation['table_name'], $columns);
|
||||
}
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if sys schema is not available
|
||||
return [
|
||||
[
|
||||
'error' => 'Sys schema not available. Enable it to get index recommendations.',
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract columns from a sample query
|
||||
*/
|
||||
private function extractColumnsFromSampleQuery(string $query, string $tableName): array
|
||||
{
|
||||
$columns = [];
|
||||
|
||||
// Extract conditions from WHERE clause
|
||||
if (preg_match('/\bWHERE\s+(.*?)(?:\bGROUP BY|\bORDER BY|\bLIMIT|\bFOR UPDATE|\bLOCK IN|\z)/is', $query, $matches)) {
|
||||
$whereClause = $matches[1];
|
||||
|
||||
// Extract column names from conditions
|
||||
preg_match_all('/\b' . preg_quote($tableName, '/') . '\.([a-zA-Z0-9_]+)\s*(?:=|<>|!=|>|<|>=|<=|LIKE|IN|NOT IN|BETWEEN|IS NULL|IS NOT NULL)/i', $whereClause, $matches);
|
||||
|
||||
if (isset($matches[1])) {
|
||||
$columns = array_unique($matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// If no columns found, use a default
|
||||
if (empty($columns)) {
|
||||
$columns = ['id'];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PostgreSQL index recommendations
|
||||
*/
|
||||
private function getPostgreSqlRecommendations(?string $table = null): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'AND t.tablename = $1';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
// Use PostgreSQL's pg_stat_statements to get index recommendations
|
||||
try {
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
schemaname AS table_schema,
|
||||
t.tablename AS table_name,
|
||||
array_agg(a.attname) AS columns,
|
||||
'index' AS type,
|
||||
NULL AS name,
|
||||
s.seq_scan AS seq_scans,
|
||||
s.seq_tup_read AS rows_examined,
|
||||
s.seq_tup_read / GREATEST(s.seq_scan, 1) AS avg_rows_examined
|
||||
FROM
|
||||
pg_stat_user_tables s
|
||||
JOIN
|
||||
pg_class c ON s.relid = c.oid
|
||||
JOIN
|
||||
pg_attribute a ON c.oid = a.attrelid
|
||||
JOIN
|
||||
pg_stat_user_tables t ON s.relid = t.relid
|
||||
WHERE
|
||||
s.seq_scan > 0
|
||||
AND a.attnum > 0
|
||||
AND NOT a.attisdropped
|
||||
AND a.attnotnull
|
||||
{$whereClause}
|
||||
GROUP BY
|
||||
schemaname, t.tablename, s.seq_scan, s.seq_tup_read
|
||||
HAVING
|
||||
COUNT(a.attname) > 0
|
||||
ORDER BY
|
||||
s.seq_tup_read DESC
|
||||
SQL;
|
||||
|
||||
$recommendations = $this->connection->query($sql, $params)->fetchAll();
|
||||
|
||||
// Process the recommendations to extract column names
|
||||
foreach ($recommendations as &$recommendation) {
|
||||
if (isset($recommendation['columns'])) {
|
||||
// Convert PostgreSQL array to PHP array
|
||||
$columns = $this->parsePostgreSqlArray($recommendation['columns']);
|
||||
$recommendation['columns'] = $columns;
|
||||
$recommendation['name'] = $this->generateIndexName($recommendation['table_name'], $columns);
|
||||
}
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if pg_stat_statements is not available
|
||||
return [
|
||||
[
|
||||
'error' => 'pg_stat_statements extension not available. Enable it to get index recommendations.',
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a PostgreSQL array string into a PHP array
|
||||
*/
|
||||
private function parsePostgreSqlArray(string $arrayString): array
|
||||
{
|
||||
// Remove the curly braces
|
||||
$arrayString = trim($arrayString, '{}');
|
||||
|
||||
// Split by comma, but respect quoted values
|
||||
$result = [];
|
||||
$current = '';
|
||||
$inQuotes = false;
|
||||
|
||||
for ($i = 0; $i < strlen($arrayString); $i++) {
|
||||
$char = $arrayString[$i];
|
||||
|
||||
if ($char === '"' && ($i === 0 || $arrayString[$i - 1] !== '\\')) {
|
||||
$inQuotes = ! $inQuotes;
|
||||
} elseif ($char === ',' && ! $inQuotes) {
|
||||
$result[] = trim($current);
|
||||
$current = '';
|
||||
} else {
|
||||
$current .= $char;
|
||||
}
|
||||
}
|
||||
|
||||
if ($current !== '') {
|
||||
$result[] = trim($current);
|
||||
}
|
||||
|
||||
// Remove quotes from values
|
||||
foreach ($result as &$value) {
|
||||
$value = trim($value, '"');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a MySQL query and recommend indexes
|
||||
*/
|
||||
private function analyzeMySqlQuery(string $sql, array $params = []): array
|
||||
{
|
||||
try {
|
||||
// Use EXPLAIN to analyze the query
|
||||
$explainSql = 'EXPLAIN ' . $sql;
|
||||
$explainResult = $this->connection->query($explainSql, $params)->fetchAll();
|
||||
|
||||
$recommendations = [];
|
||||
|
||||
foreach ($explainResult as $row) {
|
||||
// Look for table scans (type = ALL) or index scans with high row counts
|
||||
if (($row['type'] === 'ALL' || ($row['type'] === 'index' && isset($row['rows']) && $row['rows'] > 1000))
|
||||
&& isset($row['table']) && ! empty($row['possible_keys']) && isset($row['key']) && $row['key'] === null) {
|
||||
|
||||
// Extract columns from the possible_keys
|
||||
$possibleKeys = explode(',', $row['possible_keys']);
|
||||
$columns = [];
|
||||
|
||||
foreach ($possibleKeys as $key) {
|
||||
// Get the columns for this key
|
||||
$keyColumns = $this->getColumnsForKey($row['table'], trim($key));
|
||||
$columns = array_merge($columns, $keyColumns);
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
$columns = array_unique($columns);
|
||||
|
||||
if (! empty($columns)) {
|
||||
$recommendations[] = [
|
||||
'table' => $row['table'],
|
||||
'columns' => $columns,
|
||||
'name' => $this->generateIndexName($row['table'], $columns),
|
||||
'type' => 'index',
|
||||
'reason' => 'Table scan detected',
|
||||
'rows' => $row['rows'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $recommendations;
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
[
|
||||
'error' => 'Query analysis failed',
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get columns for a key in MySQL
|
||||
*/
|
||||
private function getColumnsForKey(string $table, string $key): array
|
||||
{
|
||||
try {
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
COLUMN_NAME
|
||||
FROM
|
||||
information_schema.STATISTICS
|
||||
WHERE
|
||||
TABLE_NAME = ?
|
||||
AND INDEX_NAME = ?
|
||||
ORDER BY
|
||||
SEQ_IN_INDEX
|
||||
SQL;
|
||||
|
||||
$result = $this->connection->query($sql, [$table, $key])->fetchAll();
|
||||
|
||||
$columns = [];
|
||||
foreach ($result as $row) {
|
||||
$columns[] = $row['COLUMN_NAME'];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a PostgreSQL query and recommend indexes
|
||||
*/
|
||||
private function analyzePostgreSqlQuery(string $sql, array $params = []): array
|
||||
{
|
||||
try {
|
||||
// Use EXPLAIN to analyze the query
|
||||
$explainSql = 'EXPLAIN (FORMAT JSON) ' . $sql;
|
||||
$explainResult = $this->connection->query($explainSql, $params)->fetch();
|
||||
|
||||
if (! isset($explainResult[0])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$explainJson = json_decode($explainResult[0], true);
|
||||
|
||||
if (! is_array($explainJson) || ! isset($explainJson[0]['Plan'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$recommendations = [];
|
||||
$this->analyzePostgreSqlPlan($explainJson[0]['Plan'], $recommendations);
|
||||
|
||||
return $recommendations;
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
[
|
||||
'error' => 'Query analysis failed',
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively analyze a PostgreSQL execution plan
|
||||
*/
|
||||
private function analyzePostgreSqlPlan(array $plan, array &$recommendations): void
|
||||
{
|
||||
// Look for sequential scans on tables
|
||||
if ($plan['Node Type'] === 'Seq Scan' && isset($plan['Relation Name']) && isset($plan['Filter'])) {
|
||||
$table = $plan['Relation Name'];
|
||||
|
||||
// Extract columns from the filter
|
||||
$columns = $this->extractColumnsFromPostgreSqlFilter($plan['Filter'], $table);
|
||||
|
||||
if (! empty($columns)) {
|
||||
$recommendations[] = [
|
||||
'table' => $table,
|
||||
'columns' => $columns,
|
||||
'name' => $this->generateIndexName($table, $columns),
|
||||
'type' => 'index',
|
||||
'reason' => 'Sequential scan detected',
|
||||
'rows' => $plan['Plan Rows'] ?? 0,
|
||||
'cost' => $plan['Total Cost'] ?? 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively analyze child plans
|
||||
if (isset($plan['Plans']) && is_array($plan['Plans'])) {
|
||||
foreach ($plan['Plans'] as $childPlan) {
|
||||
$this->analyzePostgreSqlPlan($childPlan, $recommendations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract columns from a PostgreSQL filter expression
|
||||
*/
|
||||
private function extractColumnsFromPostgreSqlFilter(string $filter, string $table): array
|
||||
{
|
||||
$columns = [];
|
||||
|
||||
// Extract column names from the filter
|
||||
preg_match_all('/\(([a-zA-Z0-9_]+)\s+(?:[=<>!]|~~|!~~|~~\*|!~~\*|<>|!=|>=|<=|@>|<@|&&|\?\||\?&)/i', $filter, $matches);
|
||||
|
||||
if (isset($matches[1])) {
|
||||
$columns = array_unique($matches[1]);
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MySQL index SQL
|
||||
*/
|
||||
private function generateMySqlIndexSql(
|
||||
string $table,
|
||||
array $columns,
|
||||
?string $name,
|
||||
AdvancedIndexType $indexType,
|
||||
?string $whereClause
|
||||
): string {
|
||||
$index = MySQLIndex::create($name, $columns, $indexType);
|
||||
|
||||
return $index->toSql($table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PostgreSQL index SQL
|
||||
*/
|
||||
private function generatePostgreSqlIndexSql(
|
||||
string $table,
|
||||
array $columns,
|
||||
?string $name,
|
||||
AdvancedIndexType $indexType,
|
||||
?string $whereClause
|
||||
): string {
|
||||
if ($whereClause !== null) {
|
||||
$index = PostgreSQLIndex::partial($name, $columns, $whereClause, $indexType === AdvancedIndexType::UNIQUE);
|
||||
} else {
|
||||
$index = PostgreSQLIndex::create($name, $columns, $indexType);
|
||||
}
|
||||
|
||||
return $index->toSql($table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQLite index SQL
|
||||
*/
|
||||
private function generateSqliteIndexSql(
|
||||
string $table,
|
||||
array $columns,
|
||||
?string $name,
|
||||
AdvancedIndexType $indexType,
|
||||
?string $whereClause
|
||||
): string {
|
||||
if ($whereClause !== null) {
|
||||
$index = SQLiteIndex::partial($name, $columns, $whereClause, $indexType === AdvancedIndexType::UNIQUE);
|
||||
} else {
|
||||
$index = SQLiteIndex::create($name, $columns, $indexType);
|
||||
}
|
||||
|
||||
return $index->toSql($table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database driver name
|
||||
*/
|
||||
private function getDriverName(): string
|
||||
{
|
||||
return $this->connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Index\Analysis;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Exception\DatabaseException;
|
||||
|
||||
/**
|
||||
* Analyzes index usage in the database
|
||||
*/
|
||||
final class IndexUsageAnalyzer
|
||||
{
|
||||
/**
|
||||
* @var ConnectionInterface The database connection
|
||||
*/
|
||||
private ConnectionInterface $connection;
|
||||
|
||||
/**
|
||||
* Create a new index usage analyzer
|
||||
*/
|
||||
public function __construct(ConnectionInterface $connection)
|
||||
{
|
||||
$this->connection = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about index usage
|
||||
*
|
||||
* @param string|null $table Optional table name to filter results
|
||||
* @return array<array<string, mixed>> Index usage statistics
|
||||
* @throws DatabaseException If the database doesn't support index usage statistics
|
||||
*/
|
||||
public function getIndexUsageStatistics(?string $table = null): array
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $this->getMySqlIndexUsageStatistics($table),
|
||||
'pgsql' => $this->getPostgreSqlIndexUsageStatistics($table),
|
||||
default => throw new DatabaseException("Index usage statistics not supported for {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unused indexes
|
||||
*
|
||||
* @param string|null $table Optional table name to filter results
|
||||
* @param int $minDaysSinceCreation Minimum days since index creation to consider it unused
|
||||
* @return array<array<string, mixed>> Unused indexes
|
||||
* @throws DatabaseException If the database doesn't support index usage statistics
|
||||
*/
|
||||
public function getUnusedIndexes(?string $table = null, int $minDaysSinceCreation = 30): array
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $this->getMySqlUnusedIndexes($table, $minDaysSinceCreation),
|
||||
'pgsql' => $this->getPostgreSqlUnusedIndexes($table, $minDaysSinceCreation),
|
||||
default => throw new DatabaseException("Unused index detection not supported for {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duplicate indexes
|
||||
*
|
||||
* @param string|null $table Optional table name to filter results
|
||||
* @return array<array<string, mixed>> Duplicate indexes
|
||||
* @throws DatabaseException If the database doesn't support duplicate index detection
|
||||
*/
|
||||
public function getDuplicateIndexes(?string $table = null): array
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $this->getMySqlDuplicateIndexes($table),
|
||||
'pgsql' => $this->getPostgreSqlDuplicateIndexes($table),
|
||||
default => throw new DatabaseException("Duplicate index detection not supported for {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get oversized indexes
|
||||
*
|
||||
* @param string|null $table Optional table name to filter results
|
||||
* @param int $sizeThresholdMb Size threshold in MB to consider an index oversized
|
||||
* @return array<array<string, mixed>> Oversized indexes
|
||||
* @throws DatabaseException If the database doesn't support index size statistics
|
||||
*/
|
||||
public function getOversizedIndexes(?string $table = null, int $sizeThresholdMb = 100): array
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $this->getMySqlOversizedIndexes($table, $sizeThresholdMb),
|
||||
'pgsql' => $this->getPostgreSqlOversizedIndexes($table, $sizeThresholdMb),
|
||||
default => throw new DatabaseException("Index size statistics not supported for {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fragmented indexes
|
||||
*
|
||||
* @param string|null $table Optional table name to filter results
|
||||
* @param float $fragmentationThreshold Fragmentation threshold (0.0-1.0) to consider an index fragmented
|
||||
* @return array<array<string, mixed>> Fragmented indexes
|
||||
* @throws DatabaseException If the database doesn't support index fragmentation statistics
|
||||
*/
|
||||
public function getFragmentedIndexes(?string $table = null, float $fragmentationThreshold = 0.3): array
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => $this->getMySqlFragmentedIndexes($table, $fragmentationThreshold),
|
||||
'pgsql' => $this->getPostgreSqlFragmentedIndexes($table, $fragmentationThreshold),
|
||||
default => throw new DatabaseException("Index fragmentation statistics not supported for {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MySQL index usage statistics
|
||||
*/
|
||||
private function getMySqlIndexUsageStatistics(?string $table = null): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'WHERE s.TABLE_NAME = ?';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
t.TABLE_SCHEMA AS table_schema,
|
||||
t.TABLE_NAME AS table_name,
|
||||
s.INDEX_NAME AS index_name,
|
||||
s.COLUMN_NAME AS column_name,
|
||||
t.TABLE_ROWS AS table_rows,
|
||||
s.CARDINALITY AS cardinality,
|
||||
s.SEQ_IN_INDEX AS sequence,
|
||||
IFNULL(stat.ROWS_READ, 0) AS rows_read,
|
||||
IFNULL(stat.ROWS_READ, 0) / GREATEST(t.TABLE_ROWS, 1) AS read_ratio
|
||||
FROM
|
||||
information_schema.STATISTICS s
|
||||
JOIN
|
||||
information_schema.TABLES t ON s.TABLE_SCHEMA = t.TABLE_SCHEMA AND s.TABLE_NAME = t.TABLE_NAME
|
||||
LEFT JOIN
|
||||
performance_schema.table_io_waits_summary_by_index_usage stat
|
||||
ON stat.OBJECT_SCHEMA = s.TABLE_SCHEMA
|
||||
AND stat.OBJECT_NAME = s.TABLE_NAME
|
||||
AND stat.INDEX_NAME = s.INDEX_NAME
|
||||
{$whereClause}
|
||||
ORDER BY
|
||||
t.TABLE_SCHEMA, t.TABLE_NAME, s.INDEX_NAME, s.SEQ_IN_INDEX
|
||||
SQL;
|
||||
|
||||
try {
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if performance_schema is not available
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
TABLE_SCHEMA AS table_schema,
|
||||
TABLE_NAME AS table_name,
|
||||
INDEX_NAME AS index_name,
|
||||
COLUMN_NAME AS column_name,
|
||||
SEQ_IN_INDEX AS sequence,
|
||||
CARDINALITY AS cardinality,
|
||||
NULL AS rows_read,
|
||||
NULL AS read_ratio
|
||||
FROM
|
||||
information_schema.STATISTICS
|
||||
{$whereClause}
|
||||
ORDER BY
|
||||
TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MySQL unused indexes
|
||||
*/
|
||||
private function getMySqlUnusedIndexes(?string $table = null, int $minDaysSinceCreation = 30): array
|
||||
{
|
||||
$whereClause = 'WHERE stat.INDEX_NAME IS NOT NULL AND stat.INDEX_NAME != "PRIMARY"';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause .= ' AND stat.OBJECT_NAME = ?';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
stat.OBJECT_SCHEMA AS table_schema,
|
||||
stat.OBJECT_NAME AS table_name,
|
||||
stat.INDEX_NAME AS index_name,
|
||||
stat.COUNT_STAR AS total_operations,
|
||||
stat.COUNT_READ AS read_operations,
|
||||
stat.COUNT_WRITE AS write_operations,
|
||||
stat.COUNT_FETCH AS fetch_operations
|
||||
FROM
|
||||
performance_schema.table_io_waits_summary_by_index_usage stat
|
||||
{$whereClause}
|
||||
AND stat.COUNT_STAR = 0
|
||||
ORDER BY
|
||||
stat.OBJECT_SCHEMA, stat.OBJECT_NAME, stat.INDEX_NAME
|
||||
SQL;
|
||||
|
||||
try {
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
} catch (\Throwable $e) {
|
||||
// Fallback if performance_schema is not available
|
||||
return [
|
||||
[
|
||||
'error' => 'Performance schema not available. Enable it to detect unused indexes.',
|
||||
'message' => $e->getMessage(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MySQL duplicate indexes
|
||||
*/
|
||||
private function getMySqlDuplicateIndexes(?string $table = null): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'WHERE s1.TABLE_NAME = ?';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
s1.TABLE_SCHEMA AS table_schema,
|
||||
s1.TABLE_NAME AS table_name,
|
||||
s1.INDEX_NAME AS index_name,
|
||||
s2.INDEX_NAME AS duplicate_index_name,
|
||||
GROUP_CONCAT(s1.COLUMN_NAME ORDER BY s1.SEQ_IN_INDEX) AS columns
|
||||
FROM
|
||||
information_schema.STATISTICS s1
|
||||
JOIN
|
||||
information_schema.STATISTICS s2
|
||||
ON s1.TABLE_SCHEMA = s2.TABLE_SCHEMA
|
||||
AND s1.TABLE_NAME = s2.TABLE_NAME
|
||||
AND s1.COLUMN_NAME = s2.COLUMN_NAME
|
||||
AND s1.SEQ_IN_INDEX = s2.SEQ_IN_INDEX
|
||||
AND s1.INDEX_NAME < s2.INDEX_NAME
|
||||
{$whereClause}
|
||||
GROUP BY
|
||||
s1.TABLE_SCHEMA, s1.TABLE_NAME, s1.INDEX_NAME, s2.INDEX_NAME
|
||||
HAVING
|
||||
COUNT(*) = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = s1.TABLE_SCHEMA
|
||||
AND TABLE_NAME = s1.TABLE_NAME
|
||||
AND INDEX_NAME = s1.INDEX_NAME
|
||||
)
|
||||
AND COUNT(*) = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = s2.TABLE_SCHEMA
|
||||
AND TABLE_NAME = s2.TABLE_NAME
|
||||
AND INDEX_NAME = s2.INDEX_NAME
|
||||
)
|
||||
ORDER BY
|
||||
s1.TABLE_SCHEMA, s1.TABLE_NAME, s1.INDEX_NAME
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MySQL oversized indexes
|
||||
*/
|
||||
private function getMySqlOversizedIndexes(?string $table = null, int $sizeThresholdMb = 100): array
|
||||
{
|
||||
$whereClause = 'WHERE t.TABLE_SCHEMA NOT IN ("mysql", "information_schema", "performance_schema", "sys")';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause .= ' AND t.TABLE_NAME = ?';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sizeThresholdBytes = $sizeThresholdMb * 1024 * 1024;
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
t.TABLE_SCHEMA AS table_schema,
|
||||
t.TABLE_NAME AS table_name,
|
||||
t.ENGINE AS engine,
|
||||
t.TABLE_ROWS AS table_rows,
|
||||
t.AVG_ROW_LENGTH AS avg_row_length,
|
||||
t.DATA_LENGTH AS data_length,
|
||||
t.INDEX_LENGTH AS index_length,
|
||||
ROUND(t.INDEX_LENGTH / (1024 * 1024), 2) AS index_size_mb,
|
||||
ROUND(t.DATA_LENGTH / (1024 * 1024), 2) AS data_size_mb,
|
||||
ROUND(t.INDEX_LENGTH / GREATEST(t.DATA_LENGTH, 1), 2) AS index_to_data_ratio
|
||||
FROM
|
||||
information_schema.TABLES t
|
||||
{$whereClause}
|
||||
AND t.INDEX_LENGTH > {$sizeThresholdBytes}
|
||||
ORDER BY
|
||||
t.INDEX_LENGTH DESC
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MySQL fragmented indexes
|
||||
*/
|
||||
private function getMySqlFragmentedIndexes(?string $table = null, float $fragmentationThreshold = 0.3): array
|
||||
{
|
||||
$whereClause = 'WHERE t.TABLE_SCHEMA NOT IN ("mysql", "information_schema", "performance_schema", "sys")';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause .= ' AND t.TABLE_NAME = ?';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
// MySQL doesn't provide direct fragmentation metrics, so we use data_free as an approximation
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
t.TABLE_SCHEMA AS table_schema,
|
||||
t.TABLE_NAME AS table_name,
|
||||
t.ENGINE AS engine,
|
||||
t.TABLE_ROWS AS table_rows,
|
||||
t.DATA_LENGTH AS data_length,
|
||||
t.INDEX_LENGTH AS index_length,
|
||||
t.DATA_FREE AS data_free,
|
||||
ROUND(t.DATA_FREE / GREATEST(t.DATA_LENGTH + t.INDEX_LENGTH, 1), 2) AS fragmentation_ratio
|
||||
FROM
|
||||
information_schema.TABLES t
|
||||
{$whereClause}
|
||||
AND t.DATA_FREE > 0
|
||||
AND t.DATA_FREE / GREATEST(t.DATA_LENGTH + t.INDEX_LENGTH, 1) > {$fragmentationThreshold}
|
||||
ORDER BY
|
||||
fragmentation_ratio DESC
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PostgreSQL index usage statistics
|
||||
*/
|
||||
private function getPostgreSqlIndexUsageStatistics(?string $table = null): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'WHERE t.tablename = $1';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
schemaname AS table_schema,
|
||||
t.tablename AS table_name,
|
||||
indexrelname AS index_name,
|
||||
idx_scan AS index_scans,
|
||||
idx_tup_read AS tuples_read,
|
||||
idx_tup_fetch AS tuples_fetched,
|
||||
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
|
||||
pg_relation_size(i.indexrelid) AS index_bytes
|
||||
FROM
|
||||
pg_stat_user_indexes i
|
||||
JOIN
|
||||
pg_stat_user_tables t ON i.relid = t.relid
|
||||
{$whereClause}
|
||||
ORDER BY
|
||||
schemaname, t.tablename, indexrelname
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PostgreSQL unused indexes
|
||||
*/
|
||||
private function getPostgreSqlUnusedIndexes(?string $table = null, int $minDaysSinceCreation = 30): array
|
||||
{
|
||||
$whereClause = 'WHERE idx_scan = 0 AND NOT indisprimary';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause .= ' AND t.tablename = $1';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
schemaname AS table_schema,
|
||||
t.tablename AS table_name,
|
||||
indexrelname AS index_name,
|
||||
idx_scan AS index_scans,
|
||||
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
|
||||
pg_relation_size(i.indexrelid) AS index_bytes,
|
||||
indexdef AS index_definition
|
||||
FROM
|
||||
pg_stat_user_indexes i
|
||||
JOIN
|
||||
pg_stat_user_tables t ON i.relid = t.relid
|
||||
JOIN
|
||||
pg_indexes idx ON i.schemaname = idx.schemaname AND i.indexrelname = idx.indexname
|
||||
JOIN
|
||||
pg_index pi ON i.indexrelid = pi.indexrelid
|
||||
{$whereClause}
|
||||
ORDER BY
|
||||
index_bytes DESC, schemaname, t.tablename, indexrelname
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PostgreSQL duplicate indexes
|
||||
*/
|
||||
private function getPostgreSqlDuplicateIndexes(?string $table = null): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'WHERE t.tablename = $1';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
WITH index_cols AS (
|
||||
SELECT
|
||||
i.schemaname,
|
||||
i.tablename,
|
||||
i.indexname,
|
||||
array_agg(a.attname ORDER BY c.ordinality) AS columns,
|
||||
pi.indisprimary,
|
||||
pi.indisunique
|
||||
FROM
|
||||
pg_indexes i
|
||||
JOIN
|
||||
pg_class ic ON i.indexname = ic.relname
|
||||
JOIN
|
||||
pg_index pi ON ic.oid = pi.indexrelid
|
||||
JOIN
|
||||
pg_class tc ON pi.indrelid = tc.oid AND i.tablename = tc.relname
|
||||
JOIN
|
||||
pg_namespace n ON tc.relnamespace = n.oid AND i.schemaname = n.nspname
|
||||
JOIN LATERAL
|
||||
unnest(pi.indkey) WITH ORDINALITY AS c(attnum, ordinality) ON true
|
||||
JOIN
|
||||
pg_attribute a ON a.attrelid = tc.oid AND a.attnum = c.attnum
|
||||
GROUP BY
|
||||
i.schemaname, i.tablename, i.indexname, pi.indisprimary, pi.indisunique
|
||||
)
|
||||
SELECT
|
||||
i1.schemaname AS table_schema,
|
||||
i1.tablename AS table_name,
|
||||
i1.indexname AS index_name,
|
||||
i2.indexname AS duplicate_index_name,
|
||||
i1.columns::text AS columns,
|
||||
i1.indisprimary AS is_primary,
|
||||
i1.indisunique AS is_unique,
|
||||
i2.indisprimary AS dup_is_primary,
|
||||
i2.indisunique AS dup_is_unique
|
||||
FROM
|
||||
index_cols i1
|
||||
JOIN
|
||||
index_cols i2 ON i1.schemaname = i2.schemaname
|
||||
AND i1.tablename = i2.tablename
|
||||
AND i1.columns = i2.columns
|
||||
AND i1.indexname < i2.indexname
|
||||
{$whereClause}
|
||||
ORDER BY
|
||||
i1.schemaname, i1.tablename, i1.indexname
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PostgreSQL oversized indexes
|
||||
*/
|
||||
private function getPostgreSqlOversizedIndexes(?string $table = null, int $sizeThresholdMb = 100): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'WHERE t.tablename = $1';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sizeThresholdBytes = $sizeThresholdMb * 1024 * 1024;
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
schemaname AS table_schema,
|
||||
t.tablename AS table_name,
|
||||
indexrelname AS index_name,
|
||||
idx_scan AS index_scans,
|
||||
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
|
||||
pg_relation_size(i.indexrelid) AS index_bytes,
|
||||
pg_size_pretty(pg_relation_size(i.relid)) AS table_size,
|
||||
pg_relation_size(i.relid) AS table_bytes,
|
||||
ROUND(pg_relation_size(i.indexrelid)::numeric / GREATEST(pg_relation_size(i.relid), 1), 2) AS index_to_table_ratio
|
||||
FROM
|
||||
pg_stat_user_indexes i
|
||||
JOIN
|
||||
pg_stat_user_tables t ON i.relid = t.relid
|
||||
{$whereClause}
|
||||
AND pg_relation_size(i.indexrelid) > {$sizeThresholdBytes}
|
||||
ORDER BY
|
||||
index_bytes DESC, schemaname, t.tablename, indexrelname
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PostgreSQL fragmented indexes
|
||||
*/
|
||||
private function getPostgreSqlFragmentedIndexes(?string $table = null, float $fragmentationThreshold = 0.3): array
|
||||
{
|
||||
$whereClause = '';
|
||||
$params = [];
|
||||
|
||||
if ($table !== null) {
|
||||
$whereClause = 'WHERE t.tablename = $1';
|
||||
$params[] = $table;
|
||||
}
|
||||
|
||||
$sql = <<<SQL
|
||||
SELECT
|
||||
schemaname AS table_schema,
|
||||
t.tablename AS table_name,
|
||||
indexrelname AS index_name,
|
||||
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
|
||||
pg_relation_size(i.indexrelid) AS index_bytes,
|
||||
ROUND(n_dead_tup::numeric / GREATEST(n_live_tup + n_dead_tup, 1), 2) AS fragmentation_ratio
|
||||
FROM
|
||||
pg_stat_user_indexes i
|
||||
JOIN
|
||||
pg_stat_user_tables t ON i.relid = t.relid
|
||||
{$whereClause}
|
||||
AND n_dead_tup > 0
|
||||
AND n_dead_tup::numeric / GREATEST(n_live_tup + n_dead_tup, 1) > {$fragmentationThreshold}
|
||||
ORDER BY
|
||||
fragmentation_ratio DESC, schemaname, t.tablename, indexrelname
|
||||
SQL;
|
||||
|
||||
return $this->connection->query($sql, $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database driver name
|
||||
*/
|
||||
private function getDriverName(): string
|
||||
{
|
||||
return $this->connection->getPdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
}
|
||||
}
|
||||
264
src/Framework/Database/Schema/Index/MySQLIndex.php
Normal file
264
src/Framework/Database/Schema/Index/MySQLIndex.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Index;
|
||||
|
||||
/**
|
||||
* MySQL-specific index functionality
|
||||
*/
|
||||
final class MySQLIndex
|
||||
{
|
||||
/**
|
||||
* @var AdvancedIndexDefinition The underlying index definition
|
||||
*/
|
||||
private AdvancedIndexDefinition $definition;
|
||||
|
||||
/**
|
||||
* Create a new MySQL index
|
||||
*/
|
||||
private function __construct(AdvancedIndexDefinition $definition)
|
||||
{
|
||||
$this->definition = $definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standard B-tree index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function btree(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::btree($name, $columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function unique(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::UNIQUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a primary key
|
||||
*
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function primary(array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create('PRIMARY', $columns, AdvancedIndexType::PRIMARY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a FULLTEXT index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function fulltext(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::FULLTEXT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SPATIAL index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function spatial(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::SPATIAL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a HASH index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function hash(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::hash($name, $columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RTREE index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function rtree(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::rtree($name, $columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a functional index on expressions
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $expressions The SQL expressions to index
|
||||
* @param AdvancedIndexType $type The type of index (default: standard B-tree)
|
||||
* @return self
|
||||
*/
|
||||
public static function functional(
|
||||
?string $name,
|
||||
array $expressions,
|
||||
AdvancedIndexType $type = AdvancedIndexType::INDEX
|
||||
): self {
|
||||
return new self(AdvancedIndexDefinition::functional($name, $type, $expressions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index key block size
|
||||
*
|
||||
* @param int $size The key block size in bytes
|
||||
* @return self
|
||||
*/
|
||||
public function keyBlockSize(int $size): self
|
||||
{
|
||||
$this->definition->options['key_block_size'] = $size;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index parser
|
||||
*
|
||||
* @param string $parser The parser name (e.g., 'ngram')
|
||||
* @return self
|
||||
*/
|
||||
public function parser(string $parser): self
|
||||
{
|
||||
$this->definition->options['parser'] = $parser;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index algorithm
|
||||
*
|
||||
* @param string $algorithm The algorithm (INPLACE, COPY, etc.)
|
||||
* @return self
|
||||
*/
|
||||
public function algorithm(string $algorithm): self
|
||||
{
|
||||
$this->definition->options['algorithm'] = strtoupper($algorithm);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index lock option
|
||||
*
|
||||
* @param string $lock The lock option (DEFAULT, NONE, SHARED, EXCLUSIVE)
|
||||
* @return self
|
||||
*/
|
||||
public function lock(string $lock): self
|
||||
{
|
||||
$this->definition->options['lock'] = strtoupper($lock);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index comment
|
||||
*
|
||||
* @param string $comment The comment text
|
||||
* @return self
|
||||
*/
|
||||
public function comment(string $comment): self
|
||||
{
|
||||
$this->definition->options['comment'] = $comment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index visibility
|
||||
*
|
||||
* @param bool $visible Whether the index is visible to the optimizer
|
||||
* @return self
|
||||
*/
|
||||
public function visible(bool $visible = true): self
|
||||
{
|
||||
$this->definition->options['visible'] = $visible;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for this index
|
||||
*
|
||||
* @param string $table The table name
|
||||
* @return string The SQL statement
|
||||
*/
|
||||
public function toSql(string $table): string
|
||||
{
|
||||
$sql = $this->definition->toSql('mysql', $table);
|
||||
|
||||
// Add MySQL-specific options
|
||||
$options = [];
|
||||
|
||||
if (isset($this->definition->options['key_block_size'])) {
|
||||
$options[] = "KEY_BLOCK_SIZE = {$this->definition->options['key_block_size']}";
|
||||
}
|
||||
|
||||
if (isset($this->definition->options['parser'])) {
|
||||
$options[] = "PARSER {$this->definition->options['parser']}";
|
||||
}
|
||||
|
||||
if (isset($this->definition->options['comment'])) {
|
||||
$options[] = "COMMENT '{$this->definition->options['comment']}'";
|
||||
}
|
||||
|
||||
if (isset($this->definition->options['visible'])) {
|
||||
$options[] = $this->definition->options['visible'] ? 'VISIBLE' : 'INVISIBLE';
|
||||
}
|
||||
|
||||
if (! empty($options)) {
|
||||
$sql .= ' ' . implode(' ', $options);
|
||||
}
|
||||
|
||||
// Add algorithm and lock options for ALTER TABLE statements
|
||||
if (strpos($sql, 'ALTER TABLE') === 0) {
|
||||
$algorithmAndLock = [];
|
||||
|
||||
if (isset($this->definition->options['algorithm'])) {
|
||||
$algorithmAndLock[] = "ALGORITHM = {$this->definition->options['algorithm']}";
|
||||
}
|
||||
|
||||
if (isset($this->definition->options['lock'])) {
|
||||
$algorithmAndLock[] = "LOCK = {$this->definition->options['lock']}";
|
||||
}
|
||||
|
||||
if (! empty($algorithmAndLock)) {
|
||||
// Insert after the table name
|
||||
$tableNameEnd = strpos($sql, ' ', 12) + 1; // 12 = length of "ALTER TABLE "
|
||||
$sql = substr($sql, 0, $tableNameEnd) . implode(', ', $algorithmAndLock) . ' ' . substr($sql, $tableNameEnd);
|
||||
}
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying index definition
|
||||
*/
|
||||
public function getDefinition(): AdvancedIndexDefinition
|
||||
{
|
||||
return $this->definition;
|
||||
}
|
||||
}
|
||||
279
src/Framework/Database/Schema/Index/PostgreSQLIndex.php
Normal file
279
src/Framework/Database/Schema/Index/PostgreSQLIndex.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Index;
|
||||
|
||||
/**
|
||||
* PostgreSQL-specific index functionality
|
||||
*/
|
||||
final class PostgreSQLIndex
|
||||
{
|
||||
/**
|
||||
* @var AdvancedIndexDefinition The underlying index definition
|
||||
*/
|
||||
private AdvancedIndexDefinition $definition;
|
||||
|
||||
/**
|
||||
* Create a new PostgreSQL index
|
||||
*/
|
||||
private function __construct(AdvancedIndexDefinition $definition)
|
||||
{
|
||||
$this->definition = $definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standard B-tree index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function btree(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::INDEX));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function unique(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::UNIQUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GIN index (Generalized Inverted Index)
|
||||
* Useful for indexing array values, full-text search, and JSON
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function gin(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::gin($name, $columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GiST index (Generalized Search Tree)
|
||||
* Useful for geometric data, ranges, and other complex data types
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function gist(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::gist($name, $columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BRIN index (Block Range Index)
|
||||
* Useful for very large tables with natural ordering
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function brin(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::brin($name, $columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SP-GiST index (Space-Partitioned Generalized Search Tree)
|
||||
* Useful for clustered data
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function spgist(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::spgist($name, $columns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a HASH index
|
||||
* Useful for equality comparisons
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function hash(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::HASH));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a partial index with a WHERE clause
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param string $whereClause The WHERE clause for the partial index
|
||||
* @param AdvancedIndexType $type The type of index (default: standard B-tree)
|
||||
* @return self
|
||||
*/
|
||||
public static function partial(
|
||||
?string $name,
|
||||
array $columns,
|
||||
string $whereClause,
|
||||
AdvancedIndexType $type = AdvancedIndexType::INDEX
|
||||
): self {
|
||||
return new self(AdvancedIndexDefinition::partial($name, $columns, $type, $whereClause));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a functional index on expressions
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $expressions The SQL expressions to index
|
||||
* @param AdvancedIndexType $type The type of index (default: standard B-tree)
|
||||
* @return self
|
||||
*/
|
||||
public static function functional(
|
||||
?string $name,
|
||||
array $expressions,
|
||||
AdvancedIndexType $type = AdvancedIndexType::INDEX
|
||||
): self {
|
||||
return new self(AdvancedIndexDefinition::functional($name, $type, $expressions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full-text search index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param string|null $config The text search configuration (default: 'english')
|
||||
* @return self
|
||||
*/
|
||||
public static function fulltext(?string $name, array $columns, ?string $config = 'english'): self
|
||||
{
|
||||
// In PostgreSQL, full-text search is typically implemented using GIN indexes on tsvector columns
|
||||
$expressions = [];
|
||||
foreach ($columns as $column) {
|
||||
$expressions[] = "to_tsvector('{$config}', {$column})";
|
||||
}
|
||||
|
||||
return self::functional($name, $expressions, AdvancedIndexType::GIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a spatial index for geographic data
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The geometry columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function spatial(?string $name, array $columns): self
|
||||
{
|
||||
// In PostgreSQL, spatial indexes are typically implemented using GiST
|
||||
return self::gist($name, $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a INCLUDE clause to the index
|
||||
* This allows including non-key columns in the index for covering queries
|
||||
*
|
||||
* @param array<string> $columns The columns to include
|
||||
* @return self
|
||||
*/
|
||||
public function include(array $columns): self
|
||||
{
|
||||
$this->definition->options['include'] = $columns;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the FILLFACTOR for the index
|
||||
* This determines how full the index pages are packed
|
||||
*
|
||||
* @param int $factor The fill factor (10-100)
|
||||
* @return self
|
||||
*/
|
||||
public function fillfactor(int $factor): self
|
||||
{
|
||||
if ($factor < 10 || $factor > 100) {
|
||||
throw new \InvalidArgumentException('Fill factor must be between 10 and 100');
|
||||
}
|
||||
|
||||
$this->definition->options['fillfactor'] = $factor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the index CONCURRENTLY
|
||||
* This builds the index without locking out writes
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function concurrently(): self
|
||||
{
|
||||
$this->definition->options['concurrently'] = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the TABLESPACE for the index
|
||||
*
|
||||
* @param string $tablespace The tablespace name
|
||||
* @return self
|
||||
*/
|
||||
public function tablespace(string $tablespace): self
|
||||
{
|
||||
$this->definition->options['tablespace'] = $tablespace;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for this index
|
||||
*
|
||||
* @param string $table The table name
|
||||
* @return string The SQL statement
|
||||
*/
|
||||
public function toSql(string $table): string
|
||||
{
|
||||
$sql = $this->definition->toSql('pgsql', $table);
|
||||
|
||||
// Add PostgreSQL-specific options
|
||||
if (isset($this->definition->options['include'])) {
|
||||
$includeColumns = '"' . implode('", "', $this->definition->options['include']) . '"';
|
||||
$sql .= " INCLUDE ({$includeColumns})";
|
||||
}
|
||||
|
||||
if (isset($this->definition->options['fillfactor'])) {
|
||||
$sql .= " WITH (fillfactor = {$this->definition->options['fillfactor']})";
|
||||
}
|
||||
|
||||
if (isset($this->definition->options['tablespace'])) {
|
||||
$sql .= " TABLESPACE {$this->definition->options['tablespace']}";
|
||||
}
|
||||
|
||||
if (isset($this->definition->options['concurrently']) && $this->definition->options['concurrently']) {
|
||||
// Replace "CREATE INDEX" with "CREATE INDEX CONCURRENTLY"
|
||||
$sql = str_replace('CREATE INDEX', 'CREATE INDEX CONCURRENTLY', $sql);
|
||||
// Also handle UNIQUE indexes
|
||||
$sql = str_replace('CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX CONCURRENTLY', $sql);
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying index definition
|
||||
*/
|
||||
public function getDefinition(): AdvancedIndexDefinition
|
||||
{
|
||||
return $this->definition;
|
||||
}
|
||||
}
|
||||
107
src/Framework/Database/Schema/Index/SQLiteIndex.php
Normal file
107
src/Framework/Database/Schema/Index/SQLiteIndex.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema\Index;
|
||||
|
||||
/**
|
||||
* SQLite-specific index functionality
|
||||
*/
|
||||
final class SQLiteIndex
|
||||
{
|
||||
/**
|
||||
* @var AdvancedIndexDefinition The underlying index definition
|
||||
*/
|
||||
private AdvancedIndexDefinition $definition;
|
||||
|
||||
/**
|
||||
* Create a new SQLite index
|
||||
*/
|
||||
private function __construct(AdvancedIndexDefinition $definition)
|
||||
{
|
||||
$this->definition = $definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standard index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function create(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::INDEX));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique index
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @return self
|
||||
*/
|
||||
public static function unique(?string $name, array $columns): self
|
||||
{
|
||||
return new self(AdvancedIndexDefinition::create($name, $columns, AdvancedIndexType::UNIQUE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a partial index with a WHERE clause
|
||||
*
|
||||
* @param string|null $name The name of the index
|
||||
* @param array<string> $columns The columns to index
|
||||
* @param string $whereClause The WHERE clause for the partial index
|
||||
* @param bool $unique Whether the index is unique
|
||||
* @return self
|
||||
*/
|
||||
public static function partial(
|
||||
?string $name,
|
||||
array $columns,
|
||||
string $whereClause,
|
||||
bool $unique = false
|
||||
): self {
|
||||
$type = $unique ? AdvancedIndexType::UNIQUE : AdvancedIndexType::INDEX;
|
||||
|
||||
return new self(AdvancedIndexDefinition::partial($name, $columns, $type, $whereClause));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the index to be created IF NOT EXISTS
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public function ifNotExists(): self
|
||||
{
|
||||
$this->definition->options['if_not_exists'] = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SQL for this index
|
||||
*
|
||||
* @param string $table The table name
|
||||
* @return string The SQL statement
|
||||
*/
|
||||
public function toSql(string $table): string
|
||||
{
|
||||
$sql = $this->definition->toSql('sqlite', $table);
|
||||
|
||||
// Add IF NOT EXISTS if specified
|
||||
if (isset($this->definition->options['if_not_exists']) && $this->definition->options['if_not_exists']) {
|
||||
$sql = str_replace('CREATE INDEX', 'CREATE INDEX IF NOT EXISTS', $sql);
|
||||
$sql = str_replace('CREATE UNIQUE INDEX', 'CREATE UNIQUE INDEX IF NOT EXISTS', $sql);
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying index definition
|
||||
*/
|
||||
public function getDefinition(): AdvancedIndexDefinition
|
||||
{
|
||||
return $this->definition;
|
||||
}
|
||||
}
|
||||
21
src/Framework/Database/Schema/IndexDefinition.php
Normal file
21
src/Framework/Database/Schema/IndexDefinition.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
final readonly class IndexDefinition
|
||||
{
|
||||
public ?string $name;
|
||||
|
||||
public array $columns;
|
||||
|
||||
public IndexType $type;
|
||||
|
||||
public function __construct(?string $name, array $columns, IndexType $type)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->columns = $columns;
|
||||
$this->type = $type;
|
||||
}
|
||||
}
|
||||
14
src/Framework/Database/Schema/IndexType.php
Normal file
14
src/Framework/Database/Schema/IndexType.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
enum IndexType: string
|
||||
{
|
||||
case PRIMARY = 'primary';
|
||||
case UNIQUE = 'unique';
|
||||
case INDEX = 'index';
|
||||
case FULLTEXT = 'fulltext';
|
||||
case SPATIAL = 'spatial';
|
||||
}
|
||||
275
src/Framework/Database/Schema/MySQLSchemaCompiler.php
Normal file
275
src/Framework/Database/Schema/MySQLSchemaCompiler.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
use App\Framework\Database\Schema\Commands\{
|
||||
AlterTableCommand,
|
||||
CreateTableCommand,
|
||||
DropColumnCommand,
|
||||
DropForeignCommand,
|
||||
DropIndexCommand,
|
||||
DropTableCommand,
|
||||
RawCommand,
|
||||
RenameColumnCommand,
|
||||
RenameTableCommand
|
||||
};
|
||||
|
||||
final class MySQLSchemaCompiler implements SchemaCompiler
|
||||
{
|
||||
public function compile(object $command): string|array
|
||||
{
|
||||
return match($command::class) {
|
||||
CreateTableCommand::class => $this->compileCreateTable($command),
|
||||
AlterTableCommand::class => $this->compileAlterTable($command),
|
||||
DropTableCommand::class => $this->compileDropTable($command),
|
||||
RenameTableCommand::class => $this->compileRenameTable($command),
|
||||
DropColumnCommand::class => $this->compileDropColumn($command),
|
||||
RenameColumnCommand::class => $this->compileRenameColumn($command),
|
||||
DropIndexCommand::class => $this->compileDropIndex($command),
|
||||
DropForeignCommand::class => $this->compileDropForeign($command),
|
||||
RawCommand::class => $command->sql,
|
||||
default => throw new \InvalidArgumentException('Unknown command: ' . $command::class)
|
||||
};
|
||||
}
|
||||
|
||||
private function compileCreateTable(CreateTableCommand $command): string
|
||||
{
|
||||
$blueprint = $command->blueprint;
|
||||
$sql = "CREATE TABLE";
|
||||
|
||||
if ($blueprint->temporary) {
|
||||
$sql .= " TEMPORARY";
|
||||
}
|
||||
|
||||
$sql .= " `{$command->table}` (";
|
||||
|
||||
// Columns
|
||||
$columns = [];
|
||||
foreach ($blueprint->columns as $column) {
|
||||
$columns[] = $this->compileColumn($column);
|
||||
}
|
||||
|
||||
// Add primary key from columns
|
||||
$primaryColumns = [];
|
||||
foreach ($blueprint->columns as $column) {
|
||||
if ($column->primary) {
|
||||
$primaryColumns[] = "`{$column->name}`";
|
||||
}
|
||||
}
|
||||
|
||||
if ($primaryColumns) {
|
||||
$columns[] = "PRIMARY KEY (" . implode(', ', $primaryColumns) . ")";
|
||||
}
|
||||
|
||||
// Add indexes
|
||||
foreach ($blueprint->indexes as $index) {
|
||||
$columns[] = $this->compileIndex($index);
|
||||
}
|
||||
|
||||
// Add foreign keys
|
||||
foreach ($blueprint->foreignKeys as $foreign) {
|
||||
$columns[] = $this->compileForeignKey($foreign);
|
||||
}
|
||||
|
||||
$sql .= implode(', ', $columns) . ")";
|
||||
|
||||
// Table options
|
||||
if ($blueprint->engine) {
|
||||
$sql .= " ENGINE={$blueprint->engine}";
|
||||
}
|
||||
|
||||
if ($blueprint->charset) {
|
||||
$sql .= " DEFAULT CHARSET={$blueprint->charset}";
|
||||
}
|
||||
|
||||
if ($blueprint->collation) {
|
||||
$sql .= " COLLATE={$blueprint->collation}";
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function compileColumn(ColumnDefinition $column): string
|
||||
{
|
||||
$sql = "`{$column->name}` " . $this->getColumnType($column);
|
||||
|
||||
if ($column->unsigned) {
|
||||
$sql .= " UNSIGNED";
|
||||
}
|
||||
|
||||
if (! $column->nullable) {
|
||||
$sql .= " NOT NULL";
|
||||
}
|
||||
|
||||
if ($column->hasDefault) {
|
||||
if ($column->default === 'CURRENT_TIMESTAMP') {
|
||||
$sql .= " DEFAULT CURRENT_TIMESTAMP";
|
||||
} else {
|
||||
$sql .= " DEFAULT " . $this->quoteValue($column->default);
|
||||
}
|
||||
}
|
||||
|
||||
if ($column->autoIncrement) {
|
||||
$sql .= " AUTO_INCREMENT";
|
||||
}
|
||||
|
||||
if ($column->comment) {
|
||||
$sql .= " COMMENT " . $this->quoteValue($column->comment);
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function getColumnType(ColumnDefinition $column): string
|
||||
{
|
||||
return match($column->type) {
|
||||
'increments' => 'INT AUTO_INCREMENT',
|
||||
'bigIncrements' => 'BIGINT AUTO_INCREMENT',
|
||||
'integer' => 'INT',
|
||||
'bigInteger' => 'BIGINT',
|
||||
'tinyInteger' => 'TINYINT',
|
||||
'smallInteger' => 'SMALLINT',
|
||||
'mediumInteger' => 'MEDIUMINT',
|
||||
'float' => sprintf('FLOAT(%d,%d)', $column->parameters['precision'] ?? 8, $column->parameters['scale'] ?? 2),
|
||||
'double' => $column->parameters['precision'] ? sprintf('DOUBLE(%d,%d)', $column->parameters['precision'], $column->parameters['scale'] ?? 2) : 'DOUBLE',
|
||||
'decimal' => sprintf('DECIMAL(%d,%d)', $column->parameters['precision'] ?? 8, $column->parameters['scale'] ?? 2),
|
||||
'boolean' => 'TINYINT(1)',
|
||||
'string' => sprintf('VARCHAR(%d)', $column->parameters['length'] ?? 255),
|
||||
'text' => 'TEXT',
|
||||
'mediumText' => 'MEDIUMTEXT',
|
||||
'longText' => 'LONGTEXT',
|
||||
'binary' => 'BLOB',
|
||||
'json' => 'JSON',
|
||||
'date' => 'DATE',
|
||||
'dateTime' => $column->parameters['precision'] > 0 ? sprintf('DATETIME(%d)', $column->parameters['precision']) : 'DATETIME',
|
||||
'time' => $column->parameters['precision'] > 0 ? sprintf('TIME(%d)', $column->parameters['precision']) : 'TIME',
|
||||
'timestamp' => $column->parameters['precision'] > 0 ? sprintf('TIMESTAMP(%d)', $column->parameters['precision']) : 'TIMESTAMP',
|
||||
'enum' => "ENUM('" . implode("','", $column->parameters['allowed']) . "')",
|
||||
default => throw new \InvalidArgumentException("Unknown column type: {$column->type}")
|
||||
};
|
||||
}
|
||||
|
||||
private function compileIndex(IndexDefinition $index): string
|
||||
{
|
||||
$columns = '`' . implode('`, `', $index->columns) . '`';
|
||||
|
||||
return match($index->type) {
|
||||
IndexType::UNIQUE => $index->name ? "UNIQUE KEY `{$index->name}` ({$columns})" : "UNIQUE ({$columns})",
|
||||
IndexType::INDEX => $index->name ? "KEY `{$index->name}` ({$columns})" : "KEY ({$columns})",
|
||||
IndexType::FULLTEXT => $index->name ? "FULLTEXT KEY `{$index->name}` ({$columns})" : "FULLTEXT ({$columns})",
|
||||
IndexType::SPATIAL => $index->name ? "SPATIAL KEY `{$index->name}` ({$columns})" : "SPATIAL ({$columns})",
|
||||
default => throw new \InvalidArgumentException("Unsupported index type for MySQL: {$index->type->value}")
|
||||
};
|
||||
}
|
||||
|
||||
private function compileForeignKey(ForeignKeyDefinition $foreign): string
|
||||
{
|
||||
$localColumns = '`' . implode('`, `', $foreign->columns) . '`';
|
||||
$foreignColumns = '`' . implode('`, `', $foreign->referencedColumns) . '`';
|
||||
|
||||
$sql = $foreign->name
|
||||
? "CONSTRAINT `{$foreign->name}` FOREIGN KEY ({$localColumns})"
|
||||
: "FOREIGN KEY ({$localColumns})";
|
||||
|
||||
$sql .= " REFERENCES `{$foreign->referencedTable}` ({$foreignColumns})";
|
||||
|
||||
if ($foreign->onUpdate !== ForeignKeyAction::RESTRICT) {
|
||||
$sql .= " ON UPDATE {$foreign->onUpdate->value}";
|
||||
}
|
||||
|
||||
if ($foreign->onDelete !== ForeignKeyAction::RESTRICT) {
|
||||
$sql .= " ON DELETE {$foreign->onDelete->value}";
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function compileAlterTable(AlterTableCommand $command): array
|
||||
{
|
||||
$statements = [];
|
||||
$blueprint = $command->blueprint;
|
||||
|
||||
// Add columns
|
||||
foreach ($blueprint->columns as $column) {
|
||||
$sql = "ALTER TABLE `{$command->table}` ADD COLUMN " . $this->compileColumn($column);
|
||||
|
||||
if ($column->after) {
|
||||
$sql .= " AFTER `{$column->after}`";
|
||||
} elseif ($column->first) {
|
||||
$sql .= " FIRST";
|
||||
}
|
||||
|
||||
$statements[] = $sql;
|
||||
}
|
||||
|
||||
// Process commands
|
||||
foreach ($blueprint->commands as $cmd) {
|
||||
$statements[] = $this->compile($cmd);
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
private function compileDropTable(DropTableCommand $command): string
|
||||
{
|
||||
return $command->ifExists
|
||||
? "DROP TABLE IF EXISTS `{$command->table}`"
|
||||
: "DROP TABLE `{$command->table}`";
|
||||
}
|
||||
|
||||
private function compileRenameTable(RenameTableCommand $command): string
|
||||
{
|
||||
return "RENAME TABLE `{$command->from}` TO `{$command->to}`";
|
||||
}
|
||||
|
||||
private function compileDropColumn(DropColumnCommand $command): string
|
||||
{
|
||||
$columns = array_map(fn ($col) => "`{$col}`", $command->columns);
|
||||
|
||||
return "DROP COLUMN " . implode(', DROP COLUMN ', $columns);
|
||||
}
|
||||
|
||||
private function compileRenameColumn(RenameColumnCommand $command): string
|
||||
{
|
||||
return "CHANGE COLUMN `{$command->from}` `{$command->to}`";
|
||||
}
|
||||
|
||||
private function compileDropIndex(DropIndexCommand $command): string
|
||||
{
|
||||
if (is_array($command->index)) {
|
||||
$columns = '`' . implode('`, `', $command->index) . '`';
|
||||
|
||||
return "DROP INDEX ({$columns})";
|
||||
}
|
||||
|
||||
return "DROP INDEX `{$command->index}`";
|
||||
}
|
||||
|
||||
private function compileDropForeign(DropForeignCommand $command): string
|
||||
{
|
||||
if (is_array($command->index)) {
|
||||
throw new \InvalidArgumentException("MySQL requires foreign key constraint name for dropping");
|
||||
}
|
||||
|
||||
return "DROP FOREIGN KEY `{$command->index}`";
|
||||
}
|
||||
|
||||
private function quoteValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}
|
||||
}
|
||||
228
src/Framework/Database/Schema/PostgreSQLSchemaCompiler.php
Normal file
228
src/Framework/Database/Schema/PostgreSQLSchemaCompiler.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
use App\Framework\Database\Schema\Commands\{
|
||||
AlterTableCommand,
|
||||
CreateTableCommand,
|
||||
DropColumnCommand,
|
||||
DropForeignCommand,
|
||||
DropIndexCommand,
|
||||
DropTableCommand,
|
||||
RawCommand,
|
||||
RenameColumnCommand,
|
||||
RenameTableCommand
|
||||
};
|
||||
|
||||
final class PostgreSQLSchemaCompiler implements SchemaCompiler
|
||||
{
|
||||
public function compile(object $command): string|array
|
||||
{
|
||||
return match($command::class) {
|
||||
CreateTableCommand::class => $this->compileCreateTable($command),
|
||||
AlterTableCommand::class => $this->compileAlterTable($command),
|
||||
DropTableCommand::class => $this->compileDropTable($command),
|
||||
RenameTableCommand::class => $this->compileRenameTable($command),
|
||||
DropColumnCommand::class => $this->compileDropColumn($command),
|
||||
RenameColumnCommand::class => $this->compileRenameColumn($command),
|
||||
DropIndexCommand::class => $this->compileDropIndex($command),
|
||||
DropForeignCommand::class => $this->compileDropForeign($command),
|
||||
RawCommand::class => $command->sql,
|
||||
default => throw new \InvalidArgumentException('Unknown command: ' . $command::class)
|
||||
};
|
||||
}
|
||||
|
||||
private function compileCreateTable(CreateTableCommand $command): string
|
||||
{
|
||||
$blueprint = $command->blueprint;
|
||||
$sql = "CREATE";
|
||||
|
||||
if ($blueprint->temporary) {
|
||||
$sql .= " TEMPORARY";
|
||||
}
|
||||
|
||||
$sql .= " TABLE \"{$command->table}\" (";
|
||||
|
||||
// Columns
|
||||
$columns = [];
|
||||
foreach ($blueprint->columns as $column) {
|
||||
$columns[] = $this->compileColumn($column);
|
||||
}
|
||||
|
||||
// Add primary key from columns
|
||||
$primaryColumns = [];
|
||||
foreach ($blueprint->columns as $column) {
|
||||
if ($column->primary) {
|
||||
$primaryColumns[] = "\"{$column->name}\"";
|
||||
}
|
||||
}
|
||||
|
||||
if ($primaryColumns) {
|
||||
$columns[] = "PRIMARY KEY (" . implode(', ', $primaryColumns) . ")";
|
||||
}
|
||||
|
||||
// Add indexes (will be created as separate statements)
|
||||
// Add foreign keys
|
||||
foreach ($blueprint->foreignKeys as $foreign) {
|
||||
$columns[] = $this->compileForeignKey($foreign);
|
||||
}
|
||||
|
||||
$sql .= implode(', ', $columns) . ")";
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function compileColumn(ColumnDefinition $column): string
|
||||
{
|
||||
$sql = "\"{$column->name}\" " . $this->getColumnType($column);
|
||||
|
||||
if (! $column->nullable) {
|
||||
$sql .= " NOT NULL";
|
||||
}
|
||||
|
||||
if ($column->hasDefault) {
|
||||
if ($column->default === 'CURRENT_TIMESTAMP') {
|
||||
$sql .= " DEFAULT CURRENT_TIMESTAMP";
|
||||
} else {
|
||||
$sql .= " DEFAULT " . $this->quoteValue($column->default);
|
||||
}
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function getColumnType(ColumnDefinition $column): string
|
||||
{
|
||||
return match($column->type) {
|
||||
'increments' => 'SERIAL',
|
||||
'bigIncrements' => 'BIGSERIAL',
|
||||
'integer' => 'INTEGER',
|
||||
'bigInteger' => 'BIGINT',
|
||||
'tinyInteger' => 'SMALLINT',
|
||||
'smallInteger' => 'SMALLINT',
|
||||
'mediumInteger' => 'INTEGER',
|
||||
'float' => 'REAL',
|
||||
'double' => 'DOUBLE PRECISION',
|
||||
'decimal' => sprintf('DECIMAL(%d,%d)', $column->parameters['precision'] ?? 8, $column->parameters['scale'] ?? 2),
|
||||
'boolean' => 'BOOLEAN',
|
||||
'string' => sprintf('VARCHAR(%d)', $column->parameters['length'] ?? 255),
|
||||
'text' => 'TEXT',
|
||||
'mediumText' => 'TEXT',
|
||||
'longText' => 'TEXT',
|
||||
'binary' => 'BYTEA',
|
||||
'json' => 'JSON',
|
||||
'jsonb' => 'JSONB',
|
||||
'date' => 'DATE',
|
||||
'dateTime' => $column->parameters['precision'] > 0 ? sprintf('TIMESTAMP(%d)', $column->parameters['precision']) : 'TIMESTAMP',
|
||||
'time' => $column->parameters['precision'] > 0 ? sprintf('TIME(%d)', $column->parameters['precision']) : 'TIME',
|
||||
'timestamp' => $column->parameters['precision'] > 0 ? sprintf('TIMESTAMP(%d)', $column->parameters['precision']) : 'TIMESTAMP',
|
||||
'enum' => "VARCHAR(255) CHECK (\"{$column->name}\" IN ('" . implode("','", $column->parameters['allowed']) . "'))",
|
||||
default => throw new \InvalidArgumentException("Unknown column type: {$column->type}")
|
||||
};
|
||||
}
|
||||
|
||||
private function compileForeignKey(ForeignKeyDefinition $foreign): string
|
||||
{
|
||||
$localColumns = '"' . implode('", "', $foreign->columns) . '"';
|
||||
$foreignColumns = '"' . implode('", "', $foreign->referencedColumns) . '"';
|
||||
|
||||
$sql = $foreign->name
|
||||
? "CONSTRAINT \"{$foreign->name}\" FOREIGN KEY ({$localColumns})"
|
||||
: "FOREIGN KEY ({$localColumns})";
|
||||
|
||||
$sql .= " REFERENCES \"{$foreign->referencedTable}\" ({$foreignColumns})";
|
||||
|
||||
if ($foreign->onUpdate !== ForeignKeyAction::RESTRICT) {
|
||||
$sql .= " ON UPDATE {$foreign->onUpdate->value}";
|
||||
}
|
||||
|
||||
if ($foreign->onDelete !== ForeignKeyAction::RESTRICT) {
|
||||
$sql .= " ON DELETE {$foreign->onDelete->value}";
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function compileAlterTable(AlterTableCommand $command): array
|
||||
{
|
||||
$statements = [];
|
||||
$blueprint = $command->blueprint;
|
||||
|
||||
// Add columns
|
||||
foreach ($blueprint->columns as $column) {
|
||||
$statements[] = "ALTER TABLE \"{$command->table}\" ADD COLUMN " . $this->compileColumn($column);
|
||||
}
|
||||
|
||||
// Process commands (simplified)
|
||||
foreach ($blueprint->commands as $cmd) {
|
||||
if ($cmd instanceof DropColumnCommand) {
|
||||
foreach ($cmd->columns as $col) {
|
||||
$statements[] = "ALTER TABLE \"{$command->table}\" DROP COLUMN \"{$col}\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
private function compileDropTable(DropTableCommand $command): string
|
||||
{
|
||||
return $command->ifExists
|
||||
? "DROP TABLE IF EXISTS \"{$command->table}\""
|
||||
: "DROP TABLE \"{$command->table}\"";
|
||||
}
|
||||
|
||||
private function compileRenameTable(RenameTableCommand $command): string
|
||||
{
|
||||
return "ALTER TABLE \"{$command->from}\" RENAME TO \"{$command->to}\"";
|
||||
}
|
||||
|
||||
private function compileDropColumn(DropColumnCommand $command): string
|
||||
{
|
||||
$columns = array_map(fn ($col) => "DROP COLUMN \"{$col}\"", $command->columns);
|
||||
|
||||
return implode(', ', $columns);
|
||||
}
|
||||
|
||||
private function compileRenameColumn(RenameColumnCommand $command): string
|
||||
{
|
||||
return "RENAME COLUMN \"{$command->from}\" TO \"{$command->to}\"";
|
||||
}
|
||||
|
||||
private function compileDropIndex(DropIndexCommand $command): string
|
||||
{
|
||||
if (is_array($command->index)) {
|
||||
throw new \InvalidArgumentException("PostgreSQL requires index name for dropping");
|
||||
}
|
||||
|
||||
return "DROP INDEX IF EXISTS \"{$command->index}\"";
|
||||
}
|
||||
|
||||
private function compileDropForeign(DropForeignCommand $command): string
|
||||
{
|
||||
if (is_array($command->index)) {
|
||||
throw new \InvalidArgumentException("PostgreSQL requires foreign key constraint name for dropping");
|
||||
}
|
||||
|
||||
return "DROP CONSTRAINT IF EXISTS \"{$command->index}\"";
|
||||
}
|
||||
|
||||
private function quoteValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'TRUE' : 'FALSE';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}
|
||||
}
|
||||
211
src/Framework/Database/Schema/SQLiteSchemaCompiler.php
Normal file
211
src/Framework/Database/Schema/SQLiteSchemaCompiler.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
use App\Framework\Database\Schema\Commands\{
|
||||
AlterTableCommand,
|
||||
CreateTableCommand,
|
||||
DropColumnCommand,
|
||||
DropForeignCommand,
|
||||
DropIndexCommand,
|
||||
DropTableCommand,
|
||||
RawCommand,
|
||||
RenameColumnCommand,
|
||||
RenameTableCommand
|
||||
};
|
||||
|
||||
final class SQLiteSchemaCompiler implements SchemaCompiler
|
||||
{
|
||||
public function compile(object $command): string|array
|
||||
{
|
||||
return match($command::class) {
|
||||
CreateTableCommand::class => $this->compileCreateTable($command),
|
||||
AlterTableCommand::class => $this->compileAlterTable($command),
|
||||
DropTableCommand::class => $this->compileDropTable($command),
|
||||
RenameTableCommand::class => $this->compileRenameTable($command),
|
||||
DropColumnCommand::class => $this->compileDropColumn($command),
|
||||
RenameColumnCommand::class => $this->compileRenameColumn($command),
|
||||
DropIndexCommand::class => $this->compileDropIndex($command),
|
||||
DropForeignCommand::class => $this->compileDropForeign($command),
|
||||
RawCommand::class => $command->sql,
|
||||
default => throw new \InvalidArgumentException('Unknown command: ' . $command::class)
|
||||
};
|
||||
}
|
||||
|
||||
private function compileCreateTable(CreateTableCommand $command): string
|
||||
{
|
||||
$blueprint = $command->blueprint;
|
||||
$sql = "CREATE";
|
||||
|
||||
if ($blueprint->temporary) {
|
||||
$sql .= " TEMPORARY";
|
||||
}
|
||||
|
||||
$sql .= " TABLE `{$command->table}` (";
|
||||
|
||||
// Columns
|
||||
$columns = [];
|
||||
foreach ($blueprint->columns as $column) {
|
||||
$columns[] = $this->compileColumn($column);
|
||||
}
|
||||
|
||||
// Add primary key from columns
|
||||
$primaryColumns = [];
|
||||
foreach ($blueprint->columns as $column) {
|
||||
if ($column->primary) {
|
||||
$primaryColumns[] = "`{$column->name}`";
|
||||
}
|
||||
}
|
||||
|
||||
if ($primaryColumns) {
|
||||
$columns[] = "PRIMARY KEY (" . implode(', ', $primaryColumns) . ")";
|
||||
}
|
||||
|
||||
// Add foreign keys
|
||||
foreach ($blueprint->foreignKeys as $foreign) {
|
||||
$columns[] = $this->compileForeignKey($foreign);
|
||||
}
|
||||
|
||||
$sql .= implode(', ', $columns) . ")";
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function compileColumn(ColumnDefinition $column): string
|
||||
{
|
||||
$sql = "`{$column->name}` " . $this->getColumnType($column);
|
||||
|
||||
if (! $column->nullable) {
|
||||
$sql .= " NOT NULL";
|
||||
}
|
||||
|
||||
if ($column->hasDefault) {
|
||||
if ($column->default === 'CURRENT_TIMESTAMP') {
|
||||
$sql .= " DEFAULT CURRENT_TIMESTAMP";
|
||||
} else {
|
||||
$sql .= " DEFAULT " . $this->quoteValue($column->default);
|
||||
}
|
||||
}
|
||||
|
||||
if ($column->autoIncrement) {
|
||||
$sql .= " AUTOINCREMENT";
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function getColumnType(ColumnDefinition $column): string
|
||||
{
|
||||
return match($column->type) {
|
||||
'increments' => 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
||||
'bigIncrements' => 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
||||
'integer', 'bigInteger', 'tinyInteger', 'smallInteger', 'mediumInteger' => 'INTEGER',
|
||||
'float', 'double', 'decimal' => 'REAL',
|
||||
'boolean' => 'INTEGER',
|
||||
'string' => 'TEXT',
|
||||
'text', 'mediumText', 'longText' => 'TEXT',
|
||||
'binary' => 'BLOB',
|
||||
'json', 'jsonb' => 'TEXT',
|
||||
'date', 'dateTime', 'time', 'timestamp' => 'TEXT',
|
||||
'enum' => 'TEXT CHECK (`' . $column->name . '` IN (\'' . implode("','", $column->parameters['allowed']) . '\'))',
|
||||
default => throw new \InvalidArgumentException("Unknown column type: {$column->type}")
|
||||
};
|
||||
}
|
||||
|
||||
private function compileForeignKey(ForeignKeyDefinition $foreign): string
|
||||
{
|
||||
$localColumns = '`' . implode('`, `', $foreign->columns) . '`';
|
||||
$foreignColumns = '`' . implode('`, `', $foreign->referencedColumns) . '`';
|
||||
|
||||
$sql = "FOREIGN KEY ({$localColumns}) REFERENCES `{$foreign->referencedTable}` ({$foreignColumns})";
|
||||
|
||||
if ($foreign->onUpdate !== ForeignKeyAction::RESTRICT) {
|
||||
$sql .= " ON UPDATE {$foreign->onUpdate->value}";
|
||||
}
|
||||
|
||||
if ($foreign->onDelete !== ForeignKeyAction::RESTRICT) {
|
||||
$sql .= " ON DELETE {$foreign->onDelete->value}";
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
private function compileAlterTable(AlterTableCommand $command): array
|
||||
{
|
||||
// SQLite has very limited ALTER TABLE support
|
||||
// We would need to create a new table and copy data for complex operations
|
||||
$statements = [];
|
||||
$blueprint = $command->blueprint;
|
||||
|
||||
// Add columns (SQLite supports this)
|
||||
foreach ($blueprint->columns as $column) {
|
||||
$statements[] = "ALTER TABLE `{$command->table}` ADD COLUMN " . $this->compileColumn($column);
|
||||
}
|
||||
|
||||
// For drop/rename operations, we'd need table recreation
|
||||
foreach ($blueprint->commands as $cmd) {
|
||||
if ($cmd instanceof RenameColumnCommand) {
|
||||
$statements[] = "ALTER TABLE `{$command->table}` RENAME COLUMN `{$cmd->from}` TO `{$cmd->to}`";
|
||||
}
|
||||
// Drop column would need table recreation in older SQLite versions
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
private function compileDropTable(DropTableCommand $command): string
|
||||
{
|
||||
return $command->ifExists
|
||||
? "DROP TABLE IF EXISTS `{$command->table}`"
|
||||
: "DROP TABLE `{$command->table}`";
|
||||
}
|
||||
|
||||
private function compileRenameTable(RenameTableCommand $command): string
|
||||
{
|
||||
return "ALTER TABLE `{$command->from}` RENAME TO `{$command->to}`";
|
||||
}
|
||||
|
||||
private function compileDropColumn(DropColumnCommand $command): string
|
||||
{
|
||||
// SQLite doesn't support DROP COLUMN directly - would need table recreation
|
||||
throw new \InvalidArgumentException("SQLite doesn't support dropping columns directly. Table recreation required.");
|
||||
}
|
||||
|
||||
private function compileRenameColumn(RenameColumnCommand $command): string
|
||||
{
|
||||
return "RENAME COLUMN `{$command->from}` TO `{$command->to}`";
|
||||
}
|
||||
|
||||
private function compileDropIndex(DropIndexCommand $command): string
|
||||
{
|
||||
if (is_array($command->index)) {
|
||||
throw new \InvalidArgumentException("SQLite requires index name for dropping");
|
||||
}
|
||||
|
||||
return "DROP INDEX IF EXISTS `{$command->index}`";
|
||||
}
|
||||
|
||||
private function compileDropForeign(DropForeignCommand $command): string
|
||||
{
|
||||
throw new \InvalidArgumentException("SQLite doesn't support dropping foreign key constraints directly");
|
||||
}
|
||||
|
||||
private function quoteValue(mixed $value): string
|
||||
{
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
return "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}
|
||||
}
|
||||
197
src/Framework/Database/Schema/Schema.php
Normal file
197
src/Framework/Database/Schema/Schema.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
use App\Framework\Database\ConnectionInterface;
|
||||
use App\Framework\Database\Schema\Commands\{
|
||||
AlterTableCommand,
|
||||
CreateTableCommand,
|
||||
DropTableCommand,
|
||||
RenameTableCommand
|
||||
};
|
||||
|
||||
/**
|
||||
* Database schema builder for migrations
|
||||
*/
|
||||
final class Schema
|
||||
{
|
||||
private array $commands = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ConnectionInterface $connection
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new table
|
||||
* @param string $table
|
||||
* @param callable(Blueprint): void $callback
|
||||
*/
|
||||
public function create(string $table, callable $callback): void
|
||||
{
|
||||
$blueprint = new Blueprint($table);
|
||||
$callback($blueprint);
|
||||
|
||||
$this->commands[] = new CreateTableCommand($table, $blueprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify an existing table
|
||||
* @param string $table
|
||||
* @param callable(Blueprint): void $callback
|
||||
*/
|
||||
public function table(string $table, callable $callback): void
|
||||
{
|
||||
$blueprint = new Blueprint($table);
|
||||
$callback($blueprint);
|
||||
|
||||
$this->commands[] = new AlterTableCommand($table, $blueprint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a table
|
||||
*/
|
||||
public function drop(string $table): void
|
||||
{
|
||||
$this->commands[] = new DropTableCommand($table);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a table if it exists
|
||||
*/
|
||||
public function dropIfExists(string $table): void
|
||||
{
|
||||
$this->commands[] = new DropTableCommand($table, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a table
|
||||
*/
|
||||
public function rename(string $from, string $to): void
|
||||
{
|
||||
$this->commands[] = new RenameTableCommand($from, $to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if table exists
|
||||
*/
|
||||
public function hasTable(string $table): bool
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
$query = match($driver) {
|
||||
'mysql' => "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?",
|
||||
'pgsql' => "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = ?)",
|
||||
'sqlite' => "SELECT name FROM sqlite_master WHERE type='table' AND name = ?",
|
||||
default => throw new \RuntimeException("Unsupported driver: {$driver}")
|
||||
};
|
||||
|
||||
$result = $this->connection->queryScalar($query, [$table]);
|
||||
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if column exists
|
||||
*/
|
||||
public function hasColumn(string $table, string $column): bool
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
$query = match($driver) {
|
||||
'mysql' => "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
|
||||
'pgsql' => "SELECT column_name FROM information_schema.columns WHERE table_name = ? AND column_name = ?",
|
||||
'sqlite' => "PRAGMA table_info({$table})",
|
||||
default => throw new \RuntimeException("Unsupported driver: {$driver}")
|
||||
};
|
||||
|
||||
if ($driver === 'pgsql') {
|
||||
$result = $this->connection->queryScalar($query, [$table, $column]);
|
||||
} elseif ($driver === 'sqlite') {
|
||||
$columns = $this->connection->query($query)->fetchAll();
|
||||
foreach ($columns as $col) {
|
||||
if ($col['name'] === $column) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} elseif ($driver === 'mysql') {
|
||||
$result = $this->connection->queryScalar($query, [$table, $column]);
|
||||
} else {
|
||||
throw new \RuntimeException("Unsupported driver: {$driver}");
|
||||
}
|
||||
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all schema commands
|
||||
*/
|
||||
public function execute(): void
|
||||
{
|
||||
$compiler = $this->createCompiler();
|
||||
|
||||
foreach ($this->commands as $command) {
|
||||
$sql = $compiler->compile($command);
|
||||
|
||||
if (is_array($sql)) {
|
||||
foreach ($sql as $statement) {
|
||||
$this->connection->execute($statement);
|
||||
}
|
||||
} else {
|
||||
$this->connection->execute($sql);
|
||||
}
|
||||
}
|
||||
|
||||
$this->commands = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SQL statements without executing
|
||||
*/
|
||||
public function toSql(): array
|
||||
{
|
||||
$compiler = $this->createCompiler();
|
||||
$statements = [];
|
||||
|
||||
foreach ($this->commands as $command) {
|
||||
$sql = $compiler->compile($command);
|
||||
|
||||
if (is_array($sql)) {
|
||||
$statements = array_merge($statements, $sql);
|
||||
} else {
|
||||
$statements[] = $sql;
|
||||
}
|
||||
}
|
||||
|
||||
return $statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create compiler for current database driver
|
||||
*/
|
||||
private function createCompiler(): SchemaCompiler
|
||||
{
|
||||
$driver = $this->getDriverName();
|
||||
|
||||
return match($driver) {
|
||||
'mysql' => new MySQLSchemaCompiler(),
|
||||
'pgsql' => new PostgreSQLSchemaCompiler(),
|
||||
'sqlite' => new SQLiteSchemaCompiler(),
|
||||
default => throw new \RuntimeException("Unsupported driver: {$driver}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database driver name
|
||||
*/
|
||||
private function getDriverName(): string
|
||||
{
|
||||
$pdo = $this->connection->getPdo();
|
||||
|
||||
return $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
}
|
||||
}
|
||||
13
src/Framework/Database/Schema/SchemaCompiler.php
Normal file
13
src/Framework/Database/Schema/SchemaCompiler.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Framework\Database\Schema;
|
||||
|
||||
interface SchemaCompiler
|
||||
{
|
||||
/**
|
||||
* Compile a schema command to SQL
|
||||
*/
|
||||
public function compile(object $command): string|array;
|
||||
}
|
||||
Reference in New Issue
Block a user