From 4a4a23cb164fc57082f9f9bee48dcd359816ae6b Mon Sep 17 00:00:00 2001 From: Ivan Mykhavko Date: Sun, 28 Sep 2025 19:20:22 +0000 Subject: [PATCH] Add support for DELETE with ORDER BY and LIMIT in MySQL --- .../Database/Query/Grammars/MySqlGrammar.php | 32 +++++++-- tests/Database/DatabaseMySqlBuilderTest.php | 43 +++++++++--- tests/Database/DatabaseQueryBuilderTest.php | 6 +- .../Database/EloquentDeleteTest.php | 66 +++++++++++++++---- 4 files changed, 121 insertions(+), 26 deletions(-) diff --git a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php index 6c4c2c09e212..fdecea74e7f8 100755 --- a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php @@ -461,7 +461,7 @@ public function prepareBindingsForUpdate(array $bindings, array $values) } /** - * Compile a delete query that does not use joins. + * Compile a delete statement without joins into SQL. * * @param \Illuminate\Database\Query\Builder $query * @param string $table @@ -472,9 +472,33 @@ protected function compileDeleteWithoutJoins(Builder $query, $table, $where) { $sql = parent::compileDeleteWithoutJoins($query, $table, $where); - // When using MySQL, delete statements may contain order by statements and limits - // so we will compile both of those here. Once we have finished compiling this - // we will return the completed SQL statement so it will be executed for us. + if (! empty($query->orders)) { + $sql .= ' '.$this->compileOrders($query, $query->orders); + } + + if (isset($query->limit)) { + $sql .= ' '.$this->compileLimit($query, $query->limit); + } + + return $sql; + } + + /** + * Compile a delete statement with joins into SQL. + * + * Adds ORDER BY and LIMIT if present, for platforms that allow them (e.g., PlanetScale). + * + * Standard MySQL does not support ORDER BY or LIMIT with joined deletes and will throw a syntax error. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $where + * @return string + */ + protected function compileDeleteWithJoins(Builder $query, $table, $where) + { + $sql = parent::compileDeleteWithJoins($query, $table, $where); + if (! empty($query->orders)) { $sql .= ' '.$this->compileOrders($query, $query->orders); } diff --git a/tests/Database/DatabaseMySqlBuilderTest.php b/tests/Database/DatabaseMySqlBuilderTest.php index 3317af8f2b7c..979650adc44f 100644 --- a/tests/Database/DatabaseMySqlBuilderTest.php +++ b/tests/Database/DatabaseMySqlBuilderTest.php @@ -3,22 +3,25 @@ namespace Illuminate\Tests\Database; use Illuminate\Database\Connection; -use Illuminate\Database\Schema\Grammars\MySqlGrammar; +use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\Grammars\MySqlGrammar; +use Illuminate\Database\Query\Processors\Processor; +use Illuminate\Database\Schema\Grammars\MySqlGrammar as MySqlGrammarSchema; use Illuminate\Database\Schema\MySqlBuilder; -use Mockery as m; +use Mockery; use PHPUnit\Framework\TestCase; class DatabaseMySqlBuilderTest extends TestCase { protected function tearDown(): void { - m::close(); + Mockery::close(); } - public function testCreateDatabase() + public function testCreateDatabase(): void { - $connection = m::mock(Connection::class); - $grammar = new MySqlGrammar($connection); + $connection = Mockery::mock(Connection::class); + $grammar = new MySqlGrammarSchema($connection); $connection->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8mb4'); $connection->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8mb4_unicode_ci'); @@ -33,8 +36,8 @@ public function testCreateDatabase() public function testDropDatabaseIfExists() { - $connection = m::mock(Connection::class); - $grammar = new MySqlGrammar($connection); + $connection = Mockery::mock(Connection::class); + $grammar = new MySqlGrammarSchema($connection); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('statement')->once()->with( @@ -45,4 +48,28 @@ public function testDropDatabaseIfExists() $builder->dropDatabaseIfExists('my_database_a'); } + + public function testDeleteWithJoinCompilesOrderByAndLimit(): void + { + $connection = Mockery::mock(Connection::class); + $processor = Mockery::mock(Processor::class); + $grammar = new MySqlGrammar($connection); + + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + + $builder = new Builder($connection, $grammar, $processor); + + $builder + ->from('users') + ->join('contacts', 'users.id', '=', 'contacts.id') + ->where('email', '=', 'foo') + ->orderBy('users.id') + ->limit(5); + + $sql = $grammar->compileDelete($builder); + + $this->assertStringContainsString('order by `users`.`id` asc', $sql); + $this->assertStringContainsString('limit 5', $sql); + } } diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 9ce622a415a4..908118d2cb2e 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -4478,17 +4478,17 @@ public function testDeleteWithJoinMethod() $builder = $this->getMySqlBuilder(); $builder->getConnection()->shouldReceive('delete')->once()->with('delete `users` from `users` inner join `contacts` on `users`.`id` = `contacts`.`id` where `email` = ?', ['foo'])->andReturn(1); - $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('email', '=', 'foo')->delete(); $this->assertEquals(1, $result); $builder = $this->getMySqlBuilder(); $builder->getConnection()->shouldReceive('delete')->once()->with('delete `a` from `users` as `a` inner join `users` as `b` on `a`.`id` = `b`.`user_id` where `email` = ?', ['foo'])->andReturn(1); - $result = $builder->from('users AS a')->join('users AS b', 'a.id', '=', 'b.user_id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete(); + $result = $builder->from('users AS a')->join('users AS b', 'a.id', '=', 'b.user_id')->where('email', '=', 'foo')->delete(); $this->assertEquals(1, $result); $builder = $this->getMySqlBuilder(); $builder->getConnection()->shouldReceive('delete')->once()->with('delete `users` from `users` inner join `contacts` on `users`.`id` = `contacts`.`id` where `users`.`id` = ?', [1])->andReturn(1); - $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->orderBy('id')->limit(1)->delete(1); + $result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->delete(1); $this->assertEquals(1, $result); $builder = $this->getSqlServerBuilder(); diff --git a/tests/Integration/Database/EloquentDeleteTest.php b/tests/Integration/Database/EloquentDeleteTest.php index bd0880767a52..6145417f9107 100644 --- a/tests/Integration/Database/EloquentDeleteTest.php +++ b/tests/Integration/Database/EloquentDeleteTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\QueryException; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Illuminate\Tests\Integration\Database\Fixtures\Post; @@ -33,27 +34,70 @@ protected function afterRefreshingDatabase() }); } - public function testDeleteWithLimit() + public function testDeleteUseLimitWithoutJoins(): void { - if ($this->driver === 'sqlsrv') { - $this->markTestSkipped('The limit keyword is not supported on MSSQL.'); + $totalPosts = 10; + $deleteLimit = 1; + + for ($i = 0; $i < $totalPosts; $i++) { + Post::query()->create(); + } + + // Test simple delete with limit (no join) + Post::query()->latest('id')->limit($deleteLimit)->delete(); + + $this->assertEquals($totalPosts - $deleteLimit, Post::query()->count()); + } + + public function testDeleteUseLimitWithJoins(): void + { + $ignoredDrivers = ['sqlsrv', 'mysql', 'mariadb']; + + if (in_array($this->driver, $ignoredDrivers)) { + $this->markTestSkipped("{$this->driver} does not support LIMIT on DELETE statements with JOIN clauses."); } - for ($i = 1; $i <= 10; $i++) { - Comment::create([ - 'post_id' => Post::create()->id, + $totalPosts = 10; + $deleteLimit = 1; + $whereThreshold = 8; + + for ($i = 0; $i < $totalPosts; $i++) { + Comment::query()->create([ + 'post_id' => Post::query()->create()->id, ]); } - Post::latest('id')->limit(1)->delete(); - $this->assertCount(9, Post::all()); + // Test delete with join and limit + Post::query() + ->join('comments', 'comments.post_id', '=', 'posts.id') + ->where('posts.id', '>', $whereThreshold) + ->orderBy('posts.id') + ->limit($deleteLimit) + ->delete(); + + $this->assertEquals($totalPosts - $deleteLimit, Post::query()->count()); + } + + public function testDeleteWithLimitAndJoinThrowsExceptionOnMySql(): void + { + if (! in_array($this->driver, ['mysql', 'mariadb'])) { + $this->markTestSkipped('This test only applies to MySQL/MariaDB.'); + } + + $this->expectException(QueryException::class); + + for ($i = 0; $i < 10; $i++) { + Comment::query()->create([ + 'post_id' => Post::query()->create()->id, + ]); + } - Post::join('comments', 'comments.post_id', '=', 'posts.id') - ->where('posts.id', '>', 8) + Post::query() + ->join('comments', 'comments.post_id', '=', 'posts.id') + ->where('posts.id', '>', 5) ->orderBy('posts.id') ->limit(1) ->delete(); - $this->assertCount(8, Post::all()); } public function testForceDeletedEventIsFired()