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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View 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;
}
}
}

View 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}'",
};
}
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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';
}

View 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;
}
}

View 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;
}
}

View 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'
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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';
}

View 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) . "'";
}
}

View 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) . "'";
}
}

View 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) . "'";
}
}

View 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);
}
}

View 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;
}