From 70c00df6436230198e77d39f180479f89840d269 Mon Sep 17 00:00:00 2001 From: Mathias Grimm Date: Thu, 17 Jul 2025 15:18:05 -0300 Subject: [PATCH 1/2] [12.x] Automatic/Implicit Index Creation for Foreign Keys --- config/database.php | 1 + src/Illuminate/Database/Schema/Blueprint.php | 59 ++++ .../Schema/ForeignIdColumnDefinition.php | 35 +- .../Database/Schema/ForeignKeyDefinition.php | 1 + .../Database/DatabaseSchemaBlueprintTest.php | 205 ++++++++++++ ...baseSchemaForeignKeyAutoIndexAlterTest.php | 310 ++++++++++++++++++ .../DatabaseSchemaForeignKeyAutoIndexTest.php | 278 ++++++++++++++++ 7 files changed, 887 insertions(+), 2 deletions(-) create mode 100644 tests/Database/DatabaseSchemaForeignKeyAutoIndexAlterTest.php create mode 100644 tests/Database/DatabaseSchemaForeignKeyAutoIndexTest.php diff --git a/config/database.php b/config/database.php index 8a3b731fb52e..0c226febd8ce 100644 --- a/config/database.php +++ b/config/database.php @@ -96,6 +96,7 @@ 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', + // 'foreign_key_implicit_index_creation' => true, ], 'sqlsrv' => [ diff --git a/src/Illuminate/Database/Schema/Blueprint.php b/src/Illuminate/Database/Schema/Blueprint.php index 07cb721eefbc..86991b3aee88 100755 --- a/src/Illuminate/Database/Schema/Blueprint.php +++ b/src/Illuminate/Database/Schema/Blueprint.php @@ -193,6 +193,7 @@ protected function addImpliedCommands() { $this->addFluentIndexes(); $this->addFluentCommands(); + $this->addAutoForeignKeyIndexes(); if (! $this->creating()) { $this->commands = array_map( @@ -720,6 +721,9 @@ public function foreign($columns, $name = null) $this->commands[count($this->commands) - 1] = $command; + // Mark the command for potential auto-index creation + $command->autoCreateIndex = $this->shouldAutoCreateForeignKeyIndex($columns); + return $command; } @@ -1903,4 +1907,59 @@ protected function defaultTimePrecision(): ?int { return $this->connection->getSchemaBuilder()::$defaultTimePrecision; } + + /** + * Add indexes for foreign keys that should have automatic index creation. + * + * @return void + */ + protected function addAutoForeignKeyIndexes() + { + foreach ($this->commands as $command) { + if ($command->name === 'foreign' && + isset($command->autoCreateIndex) && + $command->autoCreateIndex === true && + ! isset($command->withoutIndex)) { + $this->index($command->columns); + } + } + } + + /** + * Determine if automatic foreign key index creation is enabled. + * + * @param string|array $columns + * @return bool + */ + protected function shouldAutoCreateForeignKeyIndex($columns): bool + { + $driver = $this->connection->getDriverName(); + + // @TODO + if ($driver !== 'pgsql') { + return false; + } + + if (! $this->connection->getConfig('foreign_key_implicit_index_creation', false)) { + return false; + } + + $columns = (array) $columns; + + // Check if an index already exists for these columns + foreach ($this->commands as $command) { + if ($command->name === 'index' && $command->columns === $columns) { + return false; + } + } + + // Check if any column has an index attribute set (from fluent calls like ->index()) + foreach ($this->columns as $column) { + if (in_array($column->name, $columns) && (isset($column->index) || isset($column->unique) || isset($column->primary))) { + return false; + } + } + + return true; + } } diff --git a/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php b/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php index c7f66d19bb96..a8699e4ef50c 100644 --- a/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php +++ b/src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php @@ -13,6 +13,13 @@ class ForeignIdColumnDefinition extends ColumnDefinition */ protected $blueprint; + /** + * Whether to skip automatic index creation. + * + * @var bool + */ + protected $shouldSkipAutoIndex = false; + /** * Create a new foreign ID column definition. * @@ -26,6 +33,18 @@ public function __construct(Blueprint $blueprint, $attributes = []) $this->blueprint = $blueprint; } + /** + * Indicate that the foreign key should not have an automatically created index. + * + * @return $this + */ + public function withoutIndex() + { + $this->shouldSkipAutoIndex = true; + + return $this; + } + /** * Create a foreign key constraint on this column referencing the "id" column of the conventionally related table. * @@ -39,7 +58,13 @@ public function constrained($table = null, $column = null, $indexName = null) $table ??= $this->table; $column ??= $this->referencesModelColumn ?? 'id'; - return $this->references($column, $indexName)->on($table ?? (new Stringable($this->name))->beforeLast('_'.$column)->plural()); + $foreignKey = $this->references($column, $indexName)->on($table ?? (new Stringable($this->name))->beforeLast('_'.$column)->plural()); + + if ($this->shouldSkipAutoIndex) { + $foreignKey->withoutIndex(); + } + + return $foreignKey; } /** @@ -51,6 +76,12 @@ public function constrained($table = null, $column = null, $indexName = null) */ public function references($column, $indexName = null) { - return $this->blueprint->foreign($this->name, $indexName)->references($column); + $foreignKey = $this->blueprint->foreign($this->name, $indexName)->references($column); + + if ($this->shouldSkipAutoIndex) { + $foreignKey->withoutIndex(); + } + + return $foreignKey; } } diff --git a/src/Illuminate/Database/Schema/ForeignKeyDefinition.php b/src/Illuminate/Database/Schema/ForeignKeyDefinition.php index 1ce0361b9377..fdf41e7144fd 100644 --- a/src/Illuminate/Database/Schema/ForeignKeyDefinition.php +++ b/src/Illuminate/Database/Schema/ForeignKeyDefinition.php @@ -11,6 +11,7 @@ * @method ForeignKeyDefinition onDelete(string $action) Add an ON DELETE action * @method ForeignKeyDefinition onUpdate(string $action) Add an ON UPDATE action * @method ForeignKeyDefinition references(string|array $columns) Specify the referenced column(s) + * @method ForeignKeyDefinition withoutIndex() Disable automatic index creation for this foreign key */ class ForeignKeyDefinition extends Fluent { diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index 76c734baa369..65e0733523f7 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -655,6 +655,211 @@ public function testColumnDefault() $this->assertEquals(['alter table `posts` add `note` tinytext not null default \'this\'\'ll work too\''], $getSql('MySql')); } + public function testForeignKeyWithAutomaticIndexCreationOnPostgreSQL() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = new \Illuminate\Database\Schema\Grammars\PostgresGrammar($connection); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock('Illuminate\Database\Schema\PostgresBuilder')); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->foreignId('user_id')->constrained(); + + // Need to call toSql() to trigger addImpliedCommands + $blueprint->toSql(); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have both foreign and index commands + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id'], array_values($indexCommands)[0]->columns); + } + + public function testForeignKeyWithAutomaticIndexCreationOnSQLite() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('sqlite'); + $grammar = new \Illuminate\Database\Schema\Grammars\SQLiteGrammar($connection); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock('Illuminate\Database\Schema\SQLiteBuilder')); + $connection->shouldReceive('getServerVersion')->andReturn('3.35'); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->foreignId('user_id')->constrained(); + + // Need to call toSql() to trigger addImpliedCommands + $blueprint->toSql(); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have both foreign and index commands + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id'], array_values($indexCommands)[0]->columns); + } + + public function testForeignKeyWithoutAutomaticIndexCreationOnMySQL() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('mysql'); + $grammar = new \Illuminate\Database\Schema\Grammars\MySqlGrammar($connection); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock('Illuminate\Database\Schema\MySqlBuilder')); + $connection->shouldReceive('isMaria')->andReturn(false); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->foreignId('user_id')->constrained(); + + // Need to call toSql() to trigger addImpliedCommands + $blueprint->toSql(); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command, no index for MySQL + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testForeignKeyWithExplicitIndexDoesNotCreateDuplicateIndex() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = new \Illuminate\Database\Schema\Grammars\PostgresGrammar($connection); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock('Illuminate\Database\Schema\PostgresBuilder')); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->foreignId('user_id')->index()->constrained(); + + // Need to call toSql() to trigger addImpliedCommands + $blueprint->toSql(); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have one foreign and one index command (explicit index, not auto-created) + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + } + + public function testCompoundForeignKeyWithAutomaticIndexCreation() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('sqlite'); + $grammar = new \Illuminate\Database\Schema\Grammars\SQLiteGrammar($connection); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock('Illuminate\Database\Schema\SQLiteBuilder')); + $connection->shouldReceive('getServerVersion')->andReturn('3.35'); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->unsignedBigInteger('user_id'); + $blueprint->unsignedBigInteger('tenant_id'); + $blueprint->foreign(['user_id', 'tenant_id'])->references(['id', 'tenant_id'])->on('users'); + + // Need to call toSql() to trigger addImpliedCommands + $blueprint->toSql(); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have both foreign and index commands + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id', 'tenant_id'], array_values($indexCommands)[0]->columns); + } + + public function testForeignKeyAutomaticIndexCreationDisabledByConfig() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(false); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = new \Illuminate\Database\Schema\Grammars\PostgresGrammar($connection); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock('Illuminate\Database\Schema\PostgresBuilder')); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->foreignId('user_id')->constrained(); + + // Need to call toSql() to trigger addImpliedCommands + $blueprint->toSql(); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command when config is disabled + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testForeignKeyWithoutIndexMethod() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = new \Illuminate\Database\Schema\Grammars\PostgresGrammar($connection); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock('Illuminate\Database\Schema\PostgresBuilder')); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->unsignedBigInteger('user_id'); + $blueprint->foreign('user_id')->references('id')->on('users')->withoutIndex(); + + // Need to call toSql() to trigger addImpliedCommands + $blueprint->toSql(); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command when withoutIndex is used + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + protected function getConnection(?string $grammar = null, string $prefix = '') { $connection = m::mock(Connection::class) diff --git a/tests/Database/DatabaseSchemaForeignKeyAutoIndexAlterTest.php b/tests/Database/DatabaseSchemaForeignKeyAutoIndexAlterTest.php new file mode 100644 index 000000000000..92f91fd0e2e9 --- /dev/null +++ b/tests/Database/DatabaseSchemaForeignKeyAutoIndexAlterTest.php @@ -0,0 +1,310 @@ +shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + // Simulate ALTER table (no create command) + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreignId('user_id')->constrained(); + + // Trigger the addImpliedCommands to simulate full processing + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + + // In ALTER mode, we should have: + // 1. An 'add' command for the column + // 2. A 'foreign' command + // 3. An 'index' command (auto-created) + + $addCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'add'); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + $this->assertCount(1, $addCommands); + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id'], array_values($indexCommands)[0]->columns); + } + + public function testForeignKeyOnExistingColumnWithoutAutomaticIndexCreationOnSQLite() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('sqlite'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + // Simulate adding a foreign key to an existing column + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreign('existing_user_id')->references('id')->on('users'); + + // Trigger the addImpliedCommands + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only create foreign key, no index for SQLite (will be handled in separate PR) + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testAlterTableAddMultipleForeignKeysWithAutomaticIndexes() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + // Add multiple foreign keys in one ALTER + $blueprint->foreignId('user_id')->constrained(); + $blueprint->foreignId('category_id')->constrained(); + $blueprint->foreign('tenant_id')->references('id')->on('tenants'); + + // Trigger the addImpliedCommands + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + $addCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'add'); + + // Should have 2 add commands (foreignId creates columns), 3 foreign keys, and 3 indexes + $this->assertCount(2, $addCommands); // user_id and category_id columns + $this->assertCount(3, $foreignCommands); + $this->assertCount(3, $indexCommands); + + // Verify each index corresponds to a foreign key + $indexColumns = array_map(fn ($cmd) => $cmd->columns, array_values($indexCommands)); + $this->assertContains(['user_id'], $indexColumns); + $this->assertContains(['category_id'], $indexColumns); + $this->assertContains(['tenant_id'], $indexColumns); + } + + public function testAlterTableMixedWithAndWithoutIndex() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + // Mix of with and without index + $blueprint->foreign('auto_index_id')->references('id')->on('users'); + $blueprint->foreign('no_index_id')->references('id')->on('users')->withoutIndex(); + $blueprint->foreignId('explicit_index_id')->index()->constrained('users'); + + // First add fluent indexes + $reflectionFluent = new \ReflectionMethod($blueprint, 'addFluentIndexes'); + $reflectionFluent->setAccessible(true); + $reflectionFluent->invoke($blueprint); + + // Then add implied commands + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have 3 foreign keys but only 2 indexes (auto + explicit, not no_index) + $this->assertCount(3, $foreignCommands); + $this->assertCount(2, $indexCommands); + + // Verify which indexes were created + $indexColumns = array_map(fn ($cmd) => $cmd->columns, array_values($indexCommands)); + $this->assertContains(['auto_index_id'], $indexColumns); + $this->assertContains(['explicit_index_id'], $indexColumns); + $this->assertNotContains(['no_index_id'], $indexColumns); + } + + public function testCreateTableVsAlterTableBehavior() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + // Test CREATE table + $createBlueprint = new Blueprint($connection, 'posts'); + $createBlueprint->create(); + $createBlueprint->id(); + $createBlueprint->foreignId('user_id')->constrained(); + + $reflection = new \ReflectionMethod($createBlueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($createBlueprint); + + $createCommands = $createBlueprint->getCommands(); + $createForeignCommands = array_filter($createCommands, fn ($cmd) => $cmd->name === 'foreign'); + $createIndexCommands = array_filter($createCommands, fn ($cmd) => $cmd->name === 'index'); + + // Test ALTER table + $alterBlueprint = new Blueprint($connection, 'posts'); + $alterBlueprint->foreignId('user_id')->constrained(); + + $reflection->invoke($alterBlueprint); + + $alterCommands = $alterBlueprint->getCommands(); + $alterForeignCommands = array_filter($alterCommands, fn ($cmd) => $cmd->name === 'foreign'); + $alterIndexCommands = array_filter($alterCommands, fn ($cmd) => $cmd->name === 'index'); + + // Both should have the same number of foreign keys and indexes + $this->assertCount(1, $createForeignCommands); + $this->assertCount(1, $createIndexCommands); + $this->assertCount(1, $alterForeignCommands); + $this->assertCount(1, $alterIndexCommands); + + // ALTER should also have an 'add' command for the column + $alterAddCommands = array_filter($alterCommands, fn ($cmd) => $cmd->name === 'add'); + $this->assertCount(1, $alterAddCommands); + } + + public function testCompoundForeignKeyAlterTableWithAutomaticIndex() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + // ALTER table scenario with compound foreign key + $blueprint = new Blueprint($connection, 'orders'); + $blueprint->foreign(['user_id', 'tenant_id'])->references(['id', 'tenant_id'])->on('users'); + + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have compound foreign key and compound index + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id', 'tenant_id'], array_values($indexCommands)[0]->columns); + } + + public function testCompoundForeignKeyWithWithoutIndexMethod() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'orders'); + $blueprint->foreign(['user_id', 'tenant_id']) + ->references(['id', 'tenant_id']) + ->on('users') + ->withoutIndex(); + + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have compound foreign key but NO index + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testCompoundForeignKeyCreateTableWithExplicitIndex() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + // CREATE table with explicit index on compound columns + $blueprint = new Blueprint($connection, 'orders'); + $blueprint->create(); + $blueprint->id(); + $blueprint->unsignedBigInteger('user_id'); + $blueprint->unsignedBigInteger('tenant_id'); + $blueprint->index(['user_id', 'tenant_id']); // Explicit index + $blueprint->foreign(['user_id', 'tenant_id'])->references(['id', 'tenant_id'])->on('users'); + + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have one foreign key and one index (no duplicate) + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id', 'tenant_id'], array_values($indexCommands)[0]->columns); + } +} diff --git a/tests/Database/DatabaseSchemaForeignKeyAutoIndexTest.php b/tests/Database/DatabaseSchemaForeignKeyAutoIndexTest.php new file mode 100644 index 000000000000..a49d0f262d8e --- /dev/null +++ b/tests/Database/DatabaseSchemaForeignKeyAutoIndexTest.php @@ -0,0 +1,278 @@ +shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn(m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class)); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreignId('user_id')->constrained(); + + // Trigger the addAutoForeignKeyIndexes + $reflection = new \ReflectionMethod($blueprint, 'addAutoForeignKeyIndexes'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have both foreign and index commands + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id'], array_values($indexCommands)[0]->columns); + } + + public function testForeignKeyWithoutAutomaticIndexCreationOnSQLite() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + // Even with config enabled, SQLite should not auto-create indexes + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('sqlite'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->create(); + $blueprint->id(); + $blueprint->foreignId('user_id')->constrained(); + + // Trigger the full command processing + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command, no index for SQLite (will be handled in separate PR) + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testForeignKeyWithoutAutomaticIndexCreationOnMySQL() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('mysql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn(m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class)); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreignId('user_id')->constrained(); + + // Trigger the addAutoForeignKeyIndexes + $reflection = new \ReflectionMethod($blueprint, 'addAutoForeignKeyIndexes'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command, no index for MySQL + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testForeignKeyWithExplicitIndexDoesNotCreateDuplicateIndex() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn(m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class)->shouldReceive('getFluentCommands')->andReturn([])->getMock()); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreignId('user_id')->index()->constrained(); + + // Trigger the addFluentIndexes and addAutoForeignKeyIndexes + $reflectionFluent = new \ReflectionMethod($blueprint, 'addFluentIndexes'); + $reflectionFluent->setAccessible(true); + $reflectionFluent->invoke($blueprint); + + $reflectionAuto = new \ReflectionMethod($blueprint, 'addAutoForeignKeyIndexes'); + $reflectionAuto->setAccessible(true); + $reflectionAuto->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have one foreign and no additional index (auto-creation skipped because of explicit index) + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + } + + public function testCompoundForeignKeyWithAutomaticIndexCreation() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn(m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class)); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreign(['user_id', 'tenant_id'])->references(['id', 'tenant_id'])->on('users'); + + // Trigger the addAutoForeignKeyIndexes + $reflection = new \ReflectionMethod($blueprint, 'addAutoForeignKeyIndexes'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have both foreign and index commands + $this->assertCount(1, $foreignCommands); + $this->assertCount(1, $indexCommands); + $this->assertEquals(['user_id', 'tenant_id'], array_values($indexCommands)[0]->columns); + } + + public function testForeignKeyAutomaticIndexCreationDisabledByConfig() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(false); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn(m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class)); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreignId('user_id')->constrained(); + + // Trigger the addAutoForeignKeyIndexes + $reflection = new \ReflectionMethod($blueprint, 'addAutoForeignKeyIndexes'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command when config is disabled + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testForeignKeyWithoutIndexMethod() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn(m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class)); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreign('user_id')->references('id')->on('users')->withoutIndex(); + + // Trigger the addAutoForeignKeyIndexes + $reflection = new \ReflectionMethod($blueprint, 'addAutoForeignKeyIndexes'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command when withoutIndex is used + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + } + + public function testForeignKeyWithoutIndexMethodOnMySQL() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('mysql'); + $connection->shouldReceive('getSchemaGrammar')->andReturn(m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class)); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreign('user_id')->references('id')->on('users')->withoutIndex(); + + // Trigger the addAutoForeignKeyIndexes + $reflection = new \ReflectionMethod($blueprint, 'addAutoForeignKeyIndexes'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should only have foreign command, no index (MySQL doesn't auto-create anyway) + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + + // The withoutIndex() flag should still be set even though it has no effect on MySQL + $this->assertTrue(isset(array_values($foreignCommands)[0]->withoutIndex)); + } + + public function testForeignIdWithoutIndexConstrained() + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getConfig')->with('prefix_indexes')->andReturn(true); + $connection->shouldReceive('getConfig')->with('foreign_key_implicit_index_creation', false)->andReturn(true); + $connection->shouldReceive('getDriverName')->andReturn('pgsql'); + $grammar = m::mock(\Illuminate\Database\Schema\Grammars\Grammar::class); + $grammar->shouldReceive('getFluentCommands')->andReturn([]); + $connection->shouldReceive('getSchemaGrammar')->andReturn($grammar); + $connection->shouldReceive('getSchemaBuilder')->andReturn(m::mock()); + + $blueprint = new Blueprint($connection, 'posts'); + $blueprint->foreignId('user_id')->withoutIndex()->constrained(); + + // Trigger the addImpliedCommands to simulate full processing + $reflection = new \ReflectionMethod($blueprint, 'addImpliedCommands'); + $reflection->setAccessible(true); + $reflection->invoke($blueprint); + + $commands = $blueprint->getCommands(); + $foreignCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'foreign'); + $indexCommands = array_filter($commands, fn ($cmd) => $cmd->name === 'index'); + + // Should have foreign command but no index + $this->assertCount(1, $foreignCommands); + $this->assertCount(0, $indexCommands); + + // The withoutIndex() flag should be set + $this->assertTrue(isset(array_values($foreignCommands)[0]->withoutIndex)); + } +} From ff6d7e37bfe6f9e5d1840ce2ad2f3443a0a37a66 Mon Sep 17 00:00:00 2001 From: Mathias Grimm Date: Thu, 17 Jul 2025 15:21:10 -0300 Subject: [PATCH 2/2] [12.x] Automatic/Implicit Index Creation for Foreign Keys --- src/Illuminate/Database/Schema/Blueprint.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Illuminate/Database/Schema/Blueprint.php b/src/Illuminate/Database/Schema/Blueprint.php index 86991b3aee88..d69bb63d8aec 100755 --- a/src/Illuminate/Database/Schema/Blueprint.php +++ b/src/Illuminate/Database/Schema/Blueprint.php @@ -1916,10 +1916,7 @@ protected function defaultTimePrecision(): ?int protected function addAutoForeignKeyIndexes() { foreach ($this->commands as $command) { - if ($command->name === 'foreign' && - isset($command->autoCreateIndex) && - $command->autoCreateIndex === true && - ! isset($command->withoutIndex)) { + if ($command->name === 'foreign' && isset($command->autoCreateIndex) && $command->autoCreateIndex === true && ! isset($command->withoutIndex)) { $this->index($command->columns); } }