Skip to content
Open
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
26 changes: 26 additions & 0 deletions docs/source/index.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,32 @@ $table->addEventListener(ORMAdapterEvents::PRE_QUERY, function(ORMAdapterQueryEv
The `PRE_QUERY` event is dispatched after the QueryBuilder built the Query
and before the iteration starts. It can be useful to configure the cache.

### Lazy count loading

```php?start_inline=1
$table->createAdapter(ORMAdapter::class, [
'entity' => Employee::class,
'lazy_total_count' => true,
'lazy_filtered_count' => true,
]);
```
On large tables, `COUNT` queries can be a significant performance bottleneck. The lazy count options
let you defer these expensive queries so the table renders immediately with its row data, while the
record counts (used for the info text and pagination) are fetched in a separate background request.

Option | Type | Default | Description
------ | ---- | ------- | -----------
lazy_total_count | bool | `false` | Defer the total record count query.
lazy_filtered_count | bool | `false` | Defer the filtered record count query.

When either option is enabled, the initial response is sent without waiting for the count query.
The bundled JavaScript automatically fires a lightweight follow-up request to retrieve the real
counts and updates the table's info text and pagination once they arrive. During this brief
loading period the info text is hidden to avoid displaying placeholder values.

You can enable one or both options depending on your use case. For tables where the unfiltered
count is fast but filtering is slow, `lazy_filtered_count` alone may be sufficient.

## Elastica

```php?start_inline=1
Expand Down
2 changes: 1 addition & 1 deletion src/Adapter/AbstractAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ final public function getData(DataTableState $state, bool $raw = false): ResultS
throw new \LogicException('Adapter did not set row counts');
}

return new ResultSet($data, $query->getTotalRows(), $query->getFilteredRows());
return new ResultSet($data, $query->getTotalRows(), $query->getFilteredRows(), $query->isCountDeferred());
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/Adapter/AdapterQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class AdapterQuery
private ?int $totalRows;
private ?int $filteredRows;
private ?string $identifierPropertyPath = null;
private bool $countDeferred = false;

/** @var array<string, mixed> */
private array $data;
Expand Down Expand Up @@ -74,6 +75,18 @@ public function setIdentifierPropertyPath(?string $identifierPropertyPath): stat
return $this;
}

public function isCountDeferred(): bool
{
return $this->countDeferred;
}

public function setCountDeferred(bool $countDeferred): static
{
$this->countDeferred = $countDeferred;

return $this;
}

/**
* @template T
* @param T $default
Expand Down
29 changes: 26 additions & 3 deletions src/Adapter/Doctrine/ORMAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ interface_exists(QueryBuilderProcessorInterface::class);
* @author Robbert Beesems <robbert.beesems@omines.com>
*
* @phpstan-type HydrationMode AbstractQuery::HYDRATE_*
* @phpstan-type ORMOptions array{entity: class-string, hydrate: HydrationMode, query: QueryBuilderProcessorInterface[], criteria: QueryBuilderProcessorInterface[]}
* @phpstan-type ORMOptions array{entity: class-string, hydrate: HydrationMode, query: QueryBuilderProcessorInterface[], criteria: QueryBuilderProcessorInterface[], lazy_total_count: bool, lazy_filtered_count: bool}
*/
class ORMAdapter extends AbstractAdapter
{
Expand All @@ -64,6 +64,10 @@ class ORMAdapter extends AbstractAdapter
/** @var QueryBuilderProcessorInterface[] */
protected array $criteriaProcessors = [];

private bool $lazyTotalCount = false;

private bool $lazyFilteredCount = false;

public function __construct(?ManagerRegistry $registry = null)
{
if (null === $registry) {
Expand Down Expand Up @@ -109,11 +113,24 @@ protected function prepareQuery(AdapterQuery $query): void
/** @var Query\Expr\From $fromClause */
$fromClause = $builder->getDQLPart('from')[0];
$identifier = "{$fromClause->getAlias()}.{$this->metadata->getSingleIdentifierFieldName()}";
$query->setTotalRows($this->getCount($builder, $identifier));
$isCountRequest = $state->getDataTable()->isCountRequest();
if ($this->lazyTotalCount && !$isCountRequest) {
$query->setTotalRows(0);
} else {
$query->setTotalRows($this->getCount($builder, $identifier));
}

// Get record count after filtering
$this->buildCriteria($builder, $state);
$query->setFilteredRows($this->getCount($builder, $identifier));
if ($this->lazyFilteredCount && !$isCountRequest) {
$query->setFilteredRows(0);
} else {
$query->setFilteredRows($this->getCount($builder, $identifier));
}

if (!$isCountRequest && ($this->lazyTotalCount || $this->lazyFilteredCount)) {
$query->setCountDeferred(true);
}

// Perform mapping of all referred fields and implied fields
$aliases = $this->getAliases($query);
Expand Down Expand Up @@ -292,12 +309,16 @@ protected function configureOptions(OptionsResolver $resolver): void
'criteria' => function (Options $options) {
return [new SearchCriteriaProvider()];
},
'lazy_total_count' => false,
'lazy_filtered_count' => false,
])
->setRequired('entity')
->setAllowedTypes('entity', ['string'])
->setAllowedTypes('hydrate', 'int')
->setAllowedTypes('query', [QueryBuilderProcessorInterface::class, 'array', 'callable'])
->setAllowedTypes('criteria', [QueryBuilderProcessorInterface::class, 'array', 'callable', 'null'])
->setAllowedTypes('lazy_total_count', 'bool')
->setAllowedTypes('lazy_filtered_count', 'bool')
->setNormalizer('query', $providerNormalizer)
->setNormalizer('criteria', $providerNormalizer)
;
Expand All @@ -323,6 +344,8 @@ protected function afterConfiguration(array $options): void
$this->hydrationMode = $options['hydrate'];
$this->queryBuilderProcessors = $options['query'];
$this->criteriaProcessors = $options['criteria'];
$this->lazyTotalCount = $options['lazy_total_count'];
$this->lazyFilteredCount = $options['lazy_filtered_count'];
}

private function normalizeProcessor(callable|QueryBuilderProcessorInterface $provider): QueryBuilderProcessorInterface
Expand Down
6 changes: 6 additions & 0 deletions src/Adapter/ResultSet.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public function __construct(
private readonly \Iterator $data,
private readonly int $totalRows,
private readonly int $totalFilteredRows,
private readonly bool $countDeferred = false,
) {
}

Expand All @@ -41,6 +42,11 @@ public function getTotalDisplayRecords(): int
return $this->totalFilteredRows;
}

public function isCountDeferred(): bool
{
return $this->countDeferred;
}

public function getData(): \Iterator
{
return $this->data;
Expand Down
5 changes: 5 additions & 0 deletions src/Adapter/ResultSetInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public function getTotalRecords(): int;
*/
public function getTotalDisplayRecords(): int;

/**
* Returns whether the count was deferred and should be fetched separately.
*/
public function isCountDeferred(): bool;

/**
* Returns the raw data in the result set.
*/
Expand Down
28 changes: 27 additions & 1 deletion src/DataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ class DataTable
private ?DataTableState $state = null;
private Instantiator $instantiator;

private bool $countRequest = false;

public function isCountRequest(): bool
{
return $this->countRequest;
}

/**
* @param array<string, mixed> $options
*/
Expand Down Expand Up @@ -279,6 +286,9 @@ public function handleRequest(Request $request): static
if (null === $this->state) {
$this->state = DataTableState::fromDefaults($this);
}

$this->countRequest = (bool) $parameters->get('_dt_count', false);

$this->state->applyParameters($parameters);
}

Expand All @@ -303,12 +313,28 @@ public function getResponse(): Response
}

$resultSet = $this->getResultSet();

if ($this->countRequest) {
$response = [
'draw' => $state->getDraw(),
'recordsTotal' => $resultSet->getTotalRecords(),
'recordsFiltered' => $resultSet->getTotalDisplayRecords(),
];

$this->eventDispatcher->dispatch(new DataTablePostResponseEvent($this), DataTableEvents::POST_RESPONSE);

return new JsonResponse($response);
}
$data = iterator_to_array($resultSet->getData());
$response = [
'draw' => $state->getDraw(),
'recordsTotal' => $resultSet->getTotalRecords(),
'recordsFiltered' => $resultSet->getTotalDisplayRecords(),
'data' => iterator_to_array($resultSet->getData()),
'data' => $data,
];
if ($resultSet->isCountDeferred()) {
$response['countDeferred'] = true;
}
if ($state->isInitial()) {
$response['options'] = $this->getInitialResponse();
$response['template'] = $this->renderer->renderDataTable($this, $this->template, $this->templateParams);
Expand Down
37 changes: 36 additions & 1 deletion src/Resources/public/js/datatables.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,46 @@
}).done(function(data) {
var baseState;

function requestDeferredCount(dt, request, settings, originalResponse, drawCallback) {
if (!originalResponse.countDeferred) {
return;
}

// Hide the info element while the real counts are loading
$(settings.nTableWrapper).find('.dataTables_info').css('visibility', 'hidden');

var countRequest = $.extend(true, {}, request);
countRequest._dt = config.name;
countRequest._dt_count = 1;

$.ajax(typeof config.url === 'function' ? config.url(dt) : config.url, {
method: config.method,
data: countRequest
}).done(function(countData) {
var api = new $.fn.dataTable.Api(settings);

if (countData.draw !== api.ajax.params().draw) {
return;
}

// Update counts in the original response and re-draw the table
// so DataTables fully recalculates info text and pagination
originalResponse.recordsTotal = countData.recordsTotal;
originalResponse.recordsFiltered = countData.recordsFiltered;
delete originalResponse.countDeferred;
drawCallback(originalResponse);
}).always(function() {
$(settings.nTableWrapper).find('.dataTables_info').css('visibility', '');
});
}

// Merge all options from different sources together and add the Ajax loader
var dtOpts = $.extend({}, data.options, typeof config.options === 'function' ? {} : config.options, options, persistOptions, {
ajax: function (request, drawCallback, settings) {
if (data) {
data.draw = request.draw;
drawCallback(data);
requestDeferredCount(null, request, settings, data, drawCallback);
data = null;
if (Object.keys(state).length) {
var api = new $.fn.dataTable.Api( settings );
Expand All @@ -92,7 +126,8 @@
data: request
}).done(function(data) {
drawCallback(data);
})
requestDeferredCount(dt, request, settings, data, drawCallback);
});
}
}
});
Expand Down
40 changes: 40 additions & 0 deletions tests/Unit/Adapter/AdapterQueryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/*
* Symfony DataTables Bundle
* (c) Omines Internetbureau B.V. - https://omines.nl/
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Tests\Unit\Adapter;

use Omines\DataTablesBundle\Adapter\AdapterQuery;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableState;
use PHPUnit\Framework\TestCase;

class AdapterQueryTest extends TestCase
{
public function testCountDeferredDefaultsToFalse(): void
{
$state = new DataTableState($this->createMock(DataTable::class));
$query = new AdapterQuery($state);

$this->assertFalse($query->isCountDeferred());
}

public function testCountDeferredCanBeSet(): void
{
$state = new DataTableState($this->createMock(DataTable::class));
$query = new AdapterQuery($state);

$result = $query->setCountDeferred(true);

$this->assertTrue($query->isCountDeferred());
$this->assertSame($query, $result);
}
}
Loading
Loading