Skip to content

[12.x] Automatic/Implicit Index Creation for Foreign Keys (pgsql) #56330

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: 12.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/database.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
// 'foreign_key_implicit_index_creation' => true,
],

'sqlsrv' => [
Expand Down
56 changes: 56 additions & 0 deletions src/Illuminate/Database/Schema/Blueprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ protected function addImpliedCommands()
{
$this->addFluentIndexes();
$this->addFluentCommands();
$this->addAutoForeignKeyIndexes();

if (! $this->creating()) {
$this->commands = array_map(
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -1903,4 +1907,56 @@ 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;
}
}
35 changes: 33 additions & 2 deletions src/Illuminate/Database/Schema/ForeignIdColumnDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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.
*
Expand All @@ -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;
}

/**
Expand All @@ -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;
}
}
1 change: 1 addition & 0 deletions src/Illuminate/Database/Schema/ForeignKeyDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
205 changes: 205 additions & 0 deletions tests/Database/DatabaseSchemaBlueprintTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading