diff --git a/docs/source/index.html.md b/docs/source/index.html.md
index d0b3e77d..007208a8 100644
--- a/docs/source/index.html.md
+++ b/docs/source/index.html.md
@@ -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
diff --git a/src/Adapter/AbstractAdapter.php b/src/Adapter/AbstractAdapter.php
index 98b093f8..c20205da 100644
--- a/src/Adapter/AbstractAdapter.php
+++ b/src/Adapter/AbstractAdapter.php
@@ -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());
}
/**
diff --git a/src/Adapter/AdapterQuery.php b/src/Adapter/AdapterQuery.php
index 400d015d..9fa23ce0 100644
--- a/src/Adapter/AdapterQuery.php
+++ b/src/Adapter/AdapterQuery.php
@@ -25,6 +25,7 @@ class AdapterQuery
private ?int $totalRows;
private ?int $filteredRows;
private ?string $identifierPropertyPath = null;
+ private bool $countDeferred = false;
/** @var array */
private array $data;
@@ -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
diff --git a/src/Adapter/Doctrine/ORMAdapter.php b/src/Adapter/Doctrine/ORMAdapter.php
index f85760f2..f2c80150 100644
--- a/src/Adapter/Doctrine/ORMAdapter.php
+++ b/src/Adapter/Doctrine/ORMAdapter.php
@@ -45,7 +45,7 @@ interface_exists(QueryBuilderProcessorInterface::class);
* @author Robbert Beesems
*
* @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
{
@@ -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) {
@@ -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);
@@ -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)
;
@@ -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
diff --git a/src/Adapter/ResultSet.php b/src/Adapter/ResultSet.php
index f3c858a2..8bb14841 100644
--- a/src/Adapter/ResultSet.php
+++ b/src/Adapter/ResultSet.php
@@ -28,6 +28,7 @@ public function __construct(
private readonly \Iterator $data,
private readonly int $totalRows,
private readonly int $totalFilteredRows,
+ private readonly bool $countDeferred = false,
) {
}
@@ -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;
diff --git a/src/Adapter/ResultSetInterface.php b/src/Adapter/ResultSetInterface.php
index d481bb12..18c3ad7e 100644
--- a/src/Adapter/ResultSetInterface.php
+++ b/src/Adapter/ResultSetInterface.php
@@ -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.
*/
diff --git a/src/DataTable.php b/src/DataTable.php
index 4404bd71..274f2390 100644
--- a/src/DataTable.php
+++ b/src/DataTable.php
@@ -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 $options
*/
@@ -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);
}
@@ -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);
diff --git a/src/Resources/public/js/datatables.js b/src/Resources/public/js/datatables.js
index 935fc579..e5c8d65f 100644
--- a/src/Resources/public/js/datatables.js
+++ b/src/Resources/public/js/datatables.js
@@ -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 );
@@ -92,7 +126,8 @@
data: request
}).done(function(data) {
drawCallback(data);
- })
+ requestDeferredCount(dt, request, settings, data, drawCallback);
+ });
}
}
});
diff --git a/tests/Unit/Adapter/AdapterQueryTest.php b/tests/Unit/Adapter/AdapterQueryTest.php
new file mode 100644
index 00000000..54f7e41b
--- /dev/null
+++ b/tests/Unit/Adapter/AdapterQueryTest.php
@@ -0,0 +1,40 @@
+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);
+ }
+}
diff --git a/tests/Unit/Adapter/ORMAdapterLazyCountTest.php b/tests/Unit/Adapter/ORMAdapterLazyCountTest.php
new file mode 100644
index 00000000..5e080f39
--- /dev/null
+++ b/tests/Unit/Adapter/ORMAdapterLazyCountTest.php
@@ -0,0 +1,132 @@
+factory = $kernel->getContainer()->get(DataTableFactory::class);
+ }
+
+ public function testLazyTotalCountReturnsDeferredResultSet(): void
+ {
+ $datatable = $this->factory->create()
+ ->add('firstName', TextColumn::class)
+ ->add('lastName', TextColumn::class)
+ ->createAdapter(ORMAdapter::class, [
+ 'entity' => Employee::class,
+ 'lazy_total_count' => true,
+ ]);
+
+ $state = new DataTableState($datatable);
+ $resultSet = $datatable->getAdapter()->getData($state);
+
+ $this->assertTrue($resultSet->isCountDeferred());
+ $this->assertSame(0, $resultSet->getTotalRecords());
+ $this->assertGreaterThan(0, $resultSet->getTotalDisplayRecords());
+ }
+
+ public function testLazyFilteredCountReturnsDeferredResultSet(): void
+ {
+ $datatable = $this->factory->create()
+ ->add('firstName', TextColumn::class)
+ ->add('lastName', TextColumn::class)
+ ->createAdapter(ORMAdapter::class, [
+ 'entity' => Employee::class,
+ 'lazy_filtered_count' => true,
+ ]);
+
+ $state = new DataTableState($datatable);
+ $resultSet = $datatable->getAdapter()->getData($state);
+
+ $this->assertTrue($resultSet->isCountDeferred());
+ $this->assertGreaterThan(0, $resultSet->getTotalRecords());
+ $this->assertSame(0, $resultSet->getTotalDisplayRecords());
+ }
+
+ public function testBothLazyCountsReturnsDeferredResultSet(): void
+ {
+ $datatable = $this->factory->create()
+ ->add('firstName', TextColumn::class)
+ ->add('lastName', TextColumn::class)
+ ->createAdapter(ORMAdapter::class, [
+ 'entity' => Employee::class,
+ 'lazy_total_count' => true,
+ 'lazy_filtered_count' => true,
+ ]);
+
+ $state = new DataTableState($datatable);
+ $resultSet = $datatable->getAdapter()->getData($state);
+
+ $this->assertTrue($resultSet->isCountDeferred());
+ $this->assertSame(0, $resultSet->getTotalRecords());
+ $this->assertSame(0, $resultSet->getTotalDisplayRecords());
+ }
+
+ public function testWithoutLazyCountsReturnsNonDeferredResultSet(): void
+ {
+ $datatable = $this->factory->create()
+ ->add('firstName', TextColumn::class)
+ ->add('lastName', TextColumn::class)
+ ->createAdapter(ORMAdapter::class, [
+ 'entity' => Employee::class,
+ ]);
+
+ $state = new DataTableState($datatable);
+ $resultSet = $datatable->getAdapter()->getData($state);
+
+ $this->assertFalse($resultSet->isCountDeferred());
+ $this->assertGreaterThan(0, $resultSet->getTotalRecords());
+ $this->assertGreaterThan(0, $resultSet->getTotalDisplayRecords());
+ }
+
+ public function testCountRequestComputesActualCounts(): void
+ {
+ $datatable = $this->factory->create()
+ ->add('firstName', TextColumn::class)
+ ->add('lastName', TextColumn::class)
+ ->createAdapter(ORMAdapter::class, [
+ 'entity' => Employee::class,
+ 'lazy_total_count' => true,
+ 'lazy_filtered_count' => true,
+ ]);
+
+ // Simulate a count request
+ $datatable->handleRequest(Request::create('/foo', Request::METHOD_POST, [
+ '_dt' => $datatable->getName(),
+ '_dt_count' => 1,
+ 'draw' => 1,
+ ]));
+
+ $this->assertTrue($datatable->isCountRequest());
+
+ $resultSet = $datatable->getAdapter()->getData($datatable->getState());
+
+ // On a count request, actual counts should be computed even with lazy options
+ $this->assertFalse($resultSet->isCountDeferred());
+ $this->assertGreaterThan(0, $resultSet->getTotalRecords());
+ $this->assertGreaterThan(0, $resultSet->getTotalDisplayRecords());
+ }
+}
diff --git a/tests/Unit/Adapter/ResultSetTest.php b/tests/Unit/Adapter/ResultSetTest.php
new file mode 100644
index 00000000..042d00f3
--- /dev/null
+++ b/tests/Unit/Adapter/ResultSetTest.php
@@ -0,0 +1,37 @@
+assertFalse($resultSet->isCountDeferred());
+ $this->assertSame(10, $resultSet->getTotalRecords());
+ $this->assertSame(5, $resultSet->getTotalDisplayRecords());
+ }
+
+ public function testCountDeferredCanBeEnabled(): void
+ {
+ $resultSet = new ResultSet(new \EmptyIterator(), 0, 0, true);
+
+ $this->assertTrue($resultSet->isCountDeferred());
+ $this->assertSame(0, $resultSet->getTotalRecords());
+ $this->assertSame(0, $resultSet->getTotalDisplayRecords());
+ }
+}
diff --git a/tests/Unit/DataTableTest.php b/tests/Unit/DataTableTest.php
index 6e576999..dacfab9b 100644
--- a/tests/Unit/DataTableTest.php
+++ b/tests/Unit/DataTableTest.php
@@ -344,6 +344,74 @@ public function testInvalidDataTableTypeThrows(): void
->createFromType('foo');
}
+ public function testCountRequestFlag(): void
+ {
+ $datatable = $this->createMockDataTable()
+ ->add('foo', TextColumn::class);
+
+ $this->assertFalse($datatable->isCountRequest());
+
+ $datatable->handleRequest(Request::create('/foo', Request::METHOD_POST, [
+ '_dt' => $datatable->getName(),
+ '_dt_count' => 1,
+ 'draw' => 1,
+ ]));
+
+ $this->assertTrue($datatable->isCountRequest());
+ }
+
+ public function testCountRequestFlagDefaultsToFalse(): void
+ {
+ $datatable = $this->createMockDataTable()
+ ->add('foo', TextColumn::class);
+
+ $datatable->handleRequest(Request::create('/foo', Request::METHOD_POST, [
+ '_dt' => $datatable->getName(),
+ 'draw' => 1,
+ ]));
+
+ $this->assertFalse($datatable->isCountRequest());
+ }
+
+ public function testCountRequestReturnsMinimalResponse(): void
+ {
+ $datatable = $this->createMockDataTable()
+ ->add('foo', TextColumn::class)
+ ->createAdapter(ArrayAdapter::class);
+
+ $datatable->handleRequest(Request::create('/foo', Request::METHOD_POST, [
+ '_dt' => $datatable->getName(),
+ '_dt_count' => 1,
+ 'draw' => 42,
+ ]));
+
+ $response = $datatable->getResponse();
+ $data = json_decode($response->getContent(), true);
+
+ $this->assertSame(42, $data['draw']);
+ $this->assertArrayHasKey('recordsTotal', $data);
+ $this->assertArrayHasKey('recordsFiltered', $data);
+ $this->assertArrayNotHasKey('data', $data);
+ }
+
+ public function testNormalResponseDoesNotIncludeCountDeferred(): void
+ {
+ $datatable = $this->createMockDataTable()
+ ->add('foo', TextColumn::class)
+ ->createAdapter(ArrayAdapter::class);
+
+ $datatable->handleRequest(Request::create('/foo', Request::METHOD_POST, [
+ '_dt' => $datatable->getName(),
+ 'draw' => 1,
+ ]));
+
+ $response = $datatable->getResponse();
+ $data = json_decode($response->getContent(), true);
+
+ $this->assertArrayNotHasKey('countDeferred', $data);
+ $this->assertArrayHasKey('data', $data);
+ }
+
private function createMockDataTable(array $options = []): DataTable
{
return new DataTable($this->createMock(EventDispatcher::class), $this->createMock(DataTableExporterManager::class), $options);