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);