Skip to content

Commit 8db410b

Browse files
authored
Merge pull request #7 from dicoding-dev/backport/chunk-by-id
[Backport] `chunkById` Database query builder method
2 parents af060a6 + 1012bf1 commit 8db410b

File tree

2 files changed

+210
-0
lines changed

2 files changed

+210
-0
lines changed

src/Illuminate/Database/Query/Builder.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Illuminate\Database\ConnectionInterface;
66
use Illuminate\Database\Query\Grammars\Grammar;
77
use Illuminate\Database\Query\Processors\Processor;
8+
use RuntimeException;
89

910
class Builder {
1011

@@ -1521,6 +1522,99 @@ public function chunk($count, callable $callback)
15211522
}
15221523
}
15231524

1525+
/**
1526+
* Chunk the results of a query by comparing IDs.
1527+
*
1528+
* @param int $count
1529+
* @param callable $callback
1530+
* @param string|null $column
1531+
* @param string|null $alias
1532+
* @return bool
1533+
*/
1534+
public function chunkById(int $count, callable $callback, string|null $column = null, string|null $alias = null): bool
1535+
{
1536+
$column ??= $this->defaultKeyName();
1537+
1538+
$alias ??= $column;
1539+
1540+
$lastId = null;
1541+
1542+
$page = 1;
1543+
1544+
do {
1545+
$clone = clone $this;
1546+
1547+
// We'll execute the query for the given page and get the results. If there are
1548+
// no results we can just break and return from here. When there are results
1549+
// we will call the callback with the current chunk of these results here.
1550+
$results = $clone->forPageAfterId($count, $lastId, $column)->get();
1551+
1552+
$countResults = count($results);
1553+
1554+
if ($countResults === 0) {
1555+
break;
1556+
}
1557+
1558+
// On each chunk result set, we will pass them to the callback and then let the
1559+
// developer take care of everything within the callback, which allows us to
1560+
// keep the memory low for spinning through large result sets for working.
1561+
if ($callback($results, $page) === false) {
1562+
return false;
1563+
}
1564+
1565+
$lastId = data_get(end($results), $alias);
1566+
1567+
if ($lastId === null) {
1568+
throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result.");
1569+
}
1570+
1571+
unset($results);
1572+
1573+
$page++;
1574+
} while ($countResults === $count);
1575+
1576+
return true;
1577+
}
1578+
1579+
/**
1580+
* Constrain the query to the next "page" of results after a given ID.
1581+
*
1582+
* @param int $perPage
1583+
* @param int|null $lastId
1584+
* @param string $column
1585+
* @return $this
1586+
*/
1587+
protected function forPageAfterId(int $perPage = 15, int|null $lastId = 0, string $column = 'id'): Builder
1588+
{
1589+
$this->orders = $this->removeExistingOrdersFor($column);
1590+
1591+
if (! is_null($lastId)) {
1592+
$this->where($column, '>', $lastId);
1593+
}
1594+
1595+
return $this->orderBy($column, 'asc')
1596+
->limit($perPage);
1597+
}
1598+
1599+
/**
1600+
* Get an array with all orders with a given column removed.
1601+
*
1602+
* @param string $column
1603+
* @return array
1604+
*/
1605+
protected function removeExistingOrdersFor(string $column): array
1606+
{
1607+
return Collection::make($this->orders)
1608+
->reject(function ($order) use ($column) {
1609+
return isset($order['column']) && $order['column'] === $column;
1610+
})->values()->all();
1611+
}
1612+
1613+
private function defaultKeyName(): string
1614+
{
1615+
return 'id';
1616+
}
1617+
15241618
/**
15251619
* Get an array with the values of a given column.
15261620
*

tests/Database/DatabaseQueryBuilderTest.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
use Illuminate\Database\Query\Grammars\Grammar;
77
use Illuminate\Database\Query\Processors\Processor;
88
use Illuminate\Pagination\Factory;
9+
use Illuminate\Support\Collection;
910
use L4\Tests\BackwardCompatibleTestCase;
1011
use Mockery as m;
12+
use Mockery\MockInterface;
1113

1214
class DatabaseQueryBuilderTest extends BackwardCompatibleTestCase
1315
{
@@ -1363,6 +1365,108 @@ public function testMergeBuildersBindingOrder(): void
13631365
$this->assertEquals(['foo', 'bar', 'baz'], $builder->getBindings());
13641366
}
13651367

1368+
public function testChunkByIdOnArrays(): void
1369+
{
1370+
$builder = $this->getMockQueryBuilder();
1371+
$builder->orders[] = ['column' => 'foobar', 'direction' => 'asc'];
1372+
1373+
$chunk1 = [['someIdField' => 1], ['someIdField' => 2]];
1374+
$chunk2 = [['someIdField' => 10], ['someIdField' => 11]];
1375+
$chunk3 = [];
1376+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf();
1377+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf();
1378+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 11, 'someIdField')->andReturnSelf();
1379+
$builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3);
1380+
1381+
$callbackAssertor = m::mock(stdClass::class);
1382+
$callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1);
1383+
$callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2);
1384+
$callbackAssertor->shouldReceive('doSomething')->never()->with($chunk3);
1385+
1386+
$builder->chunkById(2, function ($results) use ($callbackAssertor) {
1387+
$callbackAssertor->doSomething($results);
1388+
}, 'someIdField');
1389+
}
1390+
1391+
public function testChunkPaginatesUsingIdWithLastChunkComplete(): void
1392+
{
1393+
$builder = $this->getMockQueryBuilder();
1394+
$builder->orders[] = ['column' => 'foobar', 'direction' => 'asc'];
1395+
1396+
$chunk1 = [(object) ['someIdField' => 1], (object) ['someIdField' => 2]];
1397+
$chunk2 = [(object) ['someIdField' => 10], (object) ['someIdField' => 11]];
1398+
$chunk3 = [];
1399+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf();
1400+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf();
1401+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 11, 'someIdField')->andReturnSelf();
1402+
$builder->shouldReceive('get')->times(3)->andReturn($chunk1, $chunk2, $chunk3);
1403+
1404+
$callbackAssertor = m::mock(stdClass::class);
1405+
$callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1);
1406+
$callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2);
1407+
$callbackAssertor->shouldReceive('doSomething')->never()->with($chunk3);
1408+
1409+
$builder->chunkById(2, function ($results) use ($callbackAssertor) {
1410+
$callbackAssertor->doSomething($results);
1411+
}, 'someIdField');
1412+
}
1413+
1414+
public function testChunkPaginatesUsingIdWithLastChunkPartial(): void
1415+
{
1416+
$builder = $this->getMockQueryBuilder();
1417+
$builder->orders[] = ['column' => 'foobar', 'direction' => 'asc'];
1418+
1419+
$chunk1 = [(object) ['someIdField' => 1], (object) ['someIdField' => 2]];
1420+
$chunk2 = [(object) ['someIdField' => 10]];
1421+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'someIdField')->andReturnSelf();
1422+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 2, 'someIdField')->andReturnSelf();
1423+
$builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2);
1424+
1425+
$callbackAssertor = m::mock(stdClass::class);
1426+
$callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1);
1427+
$callbackAssertor->shouldReceive('doSomething')->once()->with($chunk2);
1428+
1429+
$builder->chunkById(2, function ($results) use ($callbackAssertor) {
1430+
$callbackAssertor->doSomething($results);
1431+
}, 'someIdField');
1432+
}
1433+
1434+
public function testChunkPaginatesUsingIdWithCountZero(): void
1435+
{
1436+
$builder = $this->getMockQueryBuilder();
1437+
$builder->orders[] = ['column' => 'foobar', 'direction' => 'asc'];
1438+
1439+
$chunk = [];
1440+
$builder->shouldReceive('forPageAfterId')->once()->with(0, 0, 'someIdField')->andReturnSelf();
1441+
$builder->shouldReceive('get')->times(1)->andReturn($chunk);
1442+
1443+
$callbackAssertor = m::mock(stdClass::class);
1444+
$callbackAssertor->shouldReceive('doSomething')->never();
1445+
1446+
$builder->chunkById(0, function ($results) use ($callbackAssertor) {
1447+
$callbackAssertor->doSomething($results);
1448+
}, 'someIdField');
1449+
}
1450+
1451+
public function testChunkPaginatesUsingIdWithAlias(): void
1452+
{
1453+
$builder = $this->getMockQueryBuilder();
1454+
$builder->orders[] = ['column' => 'foobar', 'direction' => 'asc'];
1455+
1456+
$chunk1 = [(object) ['table_id' => 1], (object) ['table_id' => 10]];
1457+
$chunk2 = [];
1458+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 0, 'table.id')->andReturnSelf();
1459+
$builder->shouldReceive('forPageAfterId')->once()->with(2, 10, 'table.id')->andReturnSelf();
1460+
$builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2);
1461+
1462+
$callbackAssertor = m::mock(stdClass::class);
1463+
$callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1);
1464+
$callbackAssertor->shouldReceive('doSomething')->never()->with($chunk2);
1465+
1466+
$builder->chunkById(2, function ($results) use ($callbackAssertor) {
1467+
$callbackAssertor->doSomething($results);
1468+
}, 'table.id', 'table_id');
1469+
}
13661470

13671471
protected function getBuilder(): Builder
13681472
{
@@ -1411,4 +1515,16 @@ protected function getMySqlBuilderWithProcessor(): Builder
14111515
return new Builder(m::mock(ConnectionInterface::class), $grammar, $processor);
14121516
}
14131517

1518+
/**
1519+
* @return MockInterface|\Illuminate\Database\Query\Builder
1520+
*/
1521+
protected function getMockQueryBuilder(): MockInterface|Builder
1522+
{
1523+
return m::mock(Builder::class, [
1524+
m::mock(ConnectionInterface::class),
1525+
new Grammar,
1526+
m::mock(Processor::class),
1527+
])->makePartial()->shouldAllowMockingProtectedMethods();
1528+
}
1529+
14141530
}

0 commit comments

Comments
 (0)