Skip to content

Commit b38f44a

Browse files
authored
Add support for DELETE with ORDER BY and LIMIT in MySQL (laravel#57196)
1 parent db6a06a commit b38f44a

File tree

4 files changed

+121
-26
lines changed

4 files changed

+121
-26
lines changed

src/Illuminate/Database/Query/Grammars/MySqlGrammar.php

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ public function prepareBindingsForUpdate(array $bindings, array $values)
461461
}
462462

463463
/**
464-
* Compile a delete query that does not use joins.
464+
* Compile a delete statement without joins into SQL.
465465
*
466466
* @param \Illuminate\Database\Query\Builder $query
467467
* @param string $table
@@ -472,9 +472,33 @@ protected function compileDeleteWithoutJoins(Builder $query, $table, $where)
472472
{
473473
$sql = parent::compileDeleteWithoutJoins($query, $table, $where);
474474

475-
// When using MySQL, delete statements may contain order by statements and limits
476-
// so we will compile both of those here. Once we have finished compiling this
477-
// we will return the completed SQL statement so it will be executed for us.
475+
if (! empty($query->orders)) {
476+
$sql .= ' '.$this->compileOrders($query, $query->orders);
477+
}
478+
479+
if (isset($query->limit)) {
480+
$sql .= ' '.$this->compileLimit($query, $query->limit);
481+
}
482+
483+
return $sql;
484+
}
485+
486+
/**
487+
* Compile a delete statement with joins into SQL.
488+
*
489+
* Adds ORDER BY and LIMIT if present, for platforms that allow them (e.g., PlanetScale).
490+
*
491+
* Standard MySQL does not support ORDER BY or LIMIT with joined deletes and will throw a syntax error.
492+
*
493+
* @param \Illuminate\Database\Query\Builder $query
494+
* @param string $table
495+
* @param string $where
496+
* @return string
497+
*/
498+
protected function compileDeleteWithJoins(Builder $query, $table, $where)
499+
{
500+
$sql = parent::compileDeleteWithJoins($query, $table, $where);
501+
478502
if (! empty($query->orders)) {
479503
$sql .= ' '.$this->compileOrders($query, $query->orders);
480504
}

tests/Database/DatabaseMySqlBuilderTest.php

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,25 @@
33
namespace Illuminate\Tests\Database;
44

55
use Illuminate\Database\Connection;
6-
use Illuminate\Database\Schema\Grammars\MySqlGrammar;
6+
use Illuminate\Database\Query\Builder;
7+
use Illuminate\Database\Query\Grammars\MySqlGrammar;
8+
use Illuminate\Database\Query\Processors\Processor;
9+
use Illuminate\Database\Schema\Grammars\MySqlGrammar as MySqlGrammarSchema;
710
use Illuminate\Database\Schema\MySqlBuilder;
8-
use Mockery as m;
11+
use Mockery;
912
use PHPUnit\Framework\TestCase;
1013

1114
class DatabaseMySqlBuilderTest extends TestCase
1215
{
1316
protected function tearDown(): void
1417
{
15-
m::close();
18+
Mockery::close();
1619
}
1720

18-
public function testCreateDatabase()
21+
public function testCreateDatabase(): void
1922
{
20-
$connection = m::mock(Connection::class);
21-
$grammar = new MySqlGrammar($connection);
23+
$connection = Mockery::mock(Connection::class);
24+
$grammar = new MySqlGrammarSchema($connection);
2225

2326
$connection->shouldReceive('getConfig')->once()->with('charset')->andReturn('utf8mb4');
2427
$connection->shouldReceive('getConfig')->once()->with('collation')->andReturn('utf8mb4_unicode_ci');
@@ -33,8 +36,8 @@ public function testCreateDatabase()
3336

3437
public function testDropDatabaseIfExists()
3538
{
36-
$connection = m::mock(Connection::class);
37-
$grammar = new MySqlGrammar($connection);
39+
$connection = Mockery::mock(Connection::class);
40+
$grammar = new MySqlGrammarSchema($connection);
3841

3942
$connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar);
4043
$connection->shouldReceive('statement')->once()->with(
@@ -45,4 +48,28 @@ public function testDropDatabaseIfExists()
4548

4649
$builder->dropDatabaseIfExists('my_database_a');
4750
}
51+
52+
public function testDeleteWithJoinCompilesOrderByAndLimit(): void
53+
{
54+
$connection = Mockery::mock(Connection::class);
55+
$processor = Mockery::mock(Processor::class);
56+
$grammar = new MySqlGrammar($connection);
57+
58+
$connection->shouldReceive('getDatabaseName')->andReturn('database');
59+
$connection->shouldReceive('getTablePrefix')->andReturn('');
60+
61+
$builder = new Builder($connection, $grammar, $processor);
62+
63+
$builder
64+
->from('users')
65+
->join('contacts', 'users.id', '=', 'contacts.id')
66+
->where('email', '=', 'foo')
67+
->orderBy('users.id')
68+
->limit(5);
69+
70+
$sql = $grammar->compileDelete($builder);
71+
72+
$this->assertStringContainsString('order by `users`.`id` asc', $sql);
73+
$this->assertStringContainsString('limit 5', $sql);
74+
}
4875
}

tests/Database/DatabaseQueryBuilderTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4478,17 +4478,17 @@ public function testDeleteWithJoinMethod()
44784478

44794479
$builder = $this->getMySqlBuilder();
44804480
$builder->getConnection()->shouldReceive('delete')->once()->with('delete `users` from `users` inner join `contacts` on `users`.`id` = `contacts`.`id` where `email` = ?', ['foo'])->andReturn(1);
4481-
$result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete();
4481+
$result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->where('email', '=', 'foo')->delete();
44824482
$this->assertEquals(1, $result);
44834483

44844484
$builder = $this->getMySqlBuilder();
44854485
$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);
4486-
$result = $builder->from('users AS a')->join('users AS b', 'a.id', '=', 'b.user_id')->where('email', '=', 'foo')->orderBy('id')->limit(1)->delete();
4486+
$result = $builder->from('users AS a')->join('users AS b', 'a.id', '=', 'b.user_id')->where('email', '=', 'foo')->delete();
44874487
$this->assertEquals(1, $result);
44884488

44894489
$builder = $this->getMySqlBuilder();
44904490
$builder->getConnection()->shouldReceive('delete')->once()->with('delete `users` from `users` inner join `contacts` on `users`.`id` = `contacts`.`id` where `users`.`id` = ?', [1])->andReturn(1);
4491-
$result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->orderBy('id')->limit(1)->delete(1);
4491+
$result = $builder->from('users')->join('contacts', 'users.id', '=', 'contacts.id')->delete(1);
44924492
$this->assertEquals(1, $result);
44934493

44944494
$builder = $this->getSqlServerBuilder();

tests/Integration/Database/EloquentDeleteTest.php

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Database\Eloquent\Model;
66
use Illuminate\Database\Eloquent\SoftDeletes;
7+
use Illuminate\Database\QueryException;
78
use Illuminate\Database\Schema\Blueprint;
89
use Illuminate\Support\Facades\Schema;
910
use Illuminate\Tests\Integration\Database\Fixtures\Post;
@@ -33,27 +34,70 @@ protected function afterRefreshingDatabase()
3334
});
3435
}
3536

36-
public function testDeleteWithLimit()
37+
public function testDeleteUseLimitWithoutJoins(): void
3738
{
38-
if ($this->driver === 'sqlsrv') {
39-
$this->markTestSkipped('The limit keyword is not supported on MSSQL.');
39+
$totalPosts = 10;
40+
$deleteLimit = 1;
41+
42+
for ($i = 0; $i < $totalPosts; $i++) {
43+
Post::query()->create();
44+
}
45+
46+
// Test simple delete with limit (no join)
47+
Post::query()->latest('id')->limit($deleteLimit)->delete();
48+
49+
$this->assertEquals($totalPosts - $deleteLimit, Post::query()->count());
50+
}
51+
52+
public function testDeleteUseLimitWithJoins(): void
53+
{
54+
$ignoredDrivers = ['sqlsrv', 'mysql', 'mariadb'];
55+
56+
if (in_array($this->driver, $ignoredDrivers)) {
57+
$this->markTestSkipped("{$this->driver} does not support LIMIT on DELETE statements with JOIN clauses.");
4058
}
4159

42-
for ($i = 1; $i <= 10; $i++) {
43-
Comment::create([
44-
'post_id' => Post::create()->id,
60+
$totalPosts = 10;
61+
$deleteLimit = 1;
62+
$whereThreshold = 8;
63+
64+
for ($i = 0; $i < $totalPosts; $i++) {
65+
Comment::query()->create([
66+
'post_id' => Post::query()->create()->id,
4567
]);
4668
}
4769

48-
Post::latest('id')->limit(1)->delete();
49-
$this->assertCount(9, Post::all());
70+
// Test delete with join and limit
71+
Post::query()
72+
->join('comments', 'comments.post_id', '=', 'posts.id')
73+
->where('posts.id', '>', $whereThreshold)
74+
->orderBy('posts.id')
75+
->limit($deleteLimit)
76+
->delete();
77+
78+
$this->assertEquals($totalPosts - $deleteLimit, Post::query()->count());
79+
}
80+
81+
public function testDeleteWithLimitAndJoinThrowsExceptionOnMySql(): void
82+
{
83+
if (! in_array($this->driver, ['mysql', 'mariadb'])) {
84+
$this->markTestSkipped('This test only applies to MySQL/MariaDB.');
85+
}
86+
87+
$this->expectException(QueryException::class);
88+
89+
for ($i = 0; $i < 10; $i++) {
90+
Comment::query()->create([
91+
'post_id' => Post::query()->create()->id,
92+
]);
93+
}
5094

51-
Post::join('comments', 'comments.post_id', '=', 'posts.id')
52-
->where('posts.id', '>', 8)
95+
Post::query()
96+
->join('comments', 'comments.post_id', '=', 'posts.id')
97+
->where('posts.id', '>', 5)
5398
->orderBy('posts.id')
5499
->limit(1)
55100
->delete();
56-
$this->assertCount(8, Post::all());
57101
}
58102

59103
public function testForceDeletedEventIsFired()

0 commit comments

Comments
 (0)