From aad6ef0621c2422a9f56f85b06d184d7dd1ccecf Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Mon, 26 Jan 2026 12:01:27 +0100 Subject: [PATCH 1/8] Add a way to define a default query for the DataTableType --- docs/src/docs/features/query.md | 115 ++++++++++++++++++ docs/src/docs/introduction.md | 1 + ...esolvedDataTableTypeDataCollectorProxy.php | 5 +- src/DataTableBuilder.php | 3 +- src/DataTableFactory.php | 16 ++- src/Type/ResolvedDataTableType.php | 5 +- src/Type/ResolvedDataTableTypeInterface.php | 3 +- tests/Unit/DataTableBuilderTest.php | 7 +- 8 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 docs/src/docs/features/query.md diff --git a/docs/src/docs/features/query.md b/docs/src/docs/features/query.md new file mode 100644 index 00000000..5e5802de --- /dev/null +++ b/docs/src/docs/features/query.md @@ -0,0 +1,115 @@ +# Query + +The data table requires a query to fetch the data. This query can be passed directly to the factory or defined as a default option in the data table type. + +[[toc]] + +## Ways to handle the query + +There are several ways to pass the query to a DataTable. + +### Passing the query to the factory + +The query can be passed directly to the factory in the controller: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index(Request $request): Response + { + // $query can be a QueryBuilder, an array, etc. + $dataTable = $this->createDataTable(ProductDataTableType::class, $query); + $dataTable->handleRequest($request); + + return $this->render('product/index.html.twig', [ + 'products' => $dataTable->createView(), + ]); + } +} +``` + +### Defining a default value + +You can provide a default value for the `query` option in your DataTable type. This avoids having to recreate the query builder every time you create the DataTable. + +Similar to how Symfony forms allow initializing `data` when no data is provided, you can initialize the `query` option. + +To do this, add the `query` option to your DataTable type: + +```php +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; +use App\Repository\ProductRepository; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + private ProductRepository $productRepository, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'query' => $this->productRepository->createQueryBuilder('p'), + ]); + } +} +``` + +This allows you to define a default configuration that is fully overridable. + +## Overriding the option + +When you define a default `query`, you can still override it when creating the data table in the controller: + +```php +$dataTable = $this->createDataTable(ProductDataTableType::class, $customQuery); +``` + +Or by passing it in the options: + +```php +$dataTable = $this->createDataTable(ProductDataTableType::class, null, [ + 'query' => $customQuery, +]); +``` + +### Extending the default query + +If you want to reuse the default query defined in the `ProductDataTableType` and add a condition from the controller, you can use the `OptionsResolver` normalizer: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\OptionsResolver\Options; +use Doctrine\ORM\QueryBuilder; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable(ProductDataTableType::class, null, [ + 'query' => function (Options $options, $query) { + if ($query instanceof QueryBuilder) { + $query->andWhere('p.active = :active') + ->setParameter('active', true); + } + + return $query; + }, + ]); + } +} +``` diff --git a/docs/src/docs/introduction.md b/docs/src/docs/introduction.md index e1f5700e..d8304193 100644 --- a/docs/src/docs/introduction.md +++ b/docs/src/docs/introduction.md @@ -31,6 +31,7 @@ If you want to include your application here, open an issue, create a pull reque - [Exporting](features/exporting.md) with or without applied pagination, filters and personalization - [Theming](features/theming.md) of every part of the bundle using Twig - [Data source agnostic](features/extensibility.md) with Doctrine ORM supported out of the box +- [Query](features/query.md) to fetch the data from any source - [Asynchronicity](features/asynchronicity.md) thanks to integration with Turbo (with prefetching enabled by default) ## Use cases diff --git a/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php b/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php index 1637f70c..39d9d88a 100644 --- a/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php +++ b/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php @@ -9,7 +9,6 @@ use Kreyu\Bundle\DataTableBundle\DataTableFactoryInterface; use Kreyu\Bundle\DataTableBundle\DataTableInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; -use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; use Kreyu\Bundle\DataTableBundle\Type\DataTableTypeInterface; use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -42,9 +41,9 @@ public function getTypeExtensions(): array return $this->proxiedType->getTypeExtensions(); } - public function createBuilder(DataTableFactoryInterface $factory, string $name, ?ProxyQueryInterface $query = null, array $options = []): DataTableBuilderInterface + public function createBuilder(DataTableFactoryInterface $factory, string $name, array $options = []): DataTableBuilderInterface { - $builder = $this->proxiedType->createBuilder($factory, $name, $query, $options); + $builder = $this->proxiedType->createBuilder($factory, $name, $options); $builder->setAttribute('data_collector/passed_options', $options); $builder->setType($this); diff --git a/src/DataTableBuilder.php b/src/DataTableBuilder.php index 8f49c975..fe9db0f6 100755 --- a/src/DataTableBuilder.php +++ b/src/DataTableBuilder.php @@ -136,10 +136,11 @@ class DataTableBuilder extends DataTableConfigBuilder implements DataTableBuilde */ private array $unresolvedExporters = []; + private ?ProxyQueryInterface $query = null; + public function __construct( string $name, ResolvedDataTableTypeInterface $type, - private ?ProxyQueryInterface $query = null, EventDispatcherInterface $dispatcher = new EventDispatcher(), array $options = [], ) { diff --git a/src/DataTableFactory.php b/src/DataTableFactory.php index 54a4b5b3..9d1ce947 100755 --- a/src/DataTableFactory.php +++ b/src/DataTableFactory.php @@ -33,6 +33,16 @@ public function createNamedBuilder(string $name, string $type = DataTableType::c { $query = $data; + $type = $this->registry->getType($type); + + $builder = $type->createBuilder($this, $name, $options); + + $type->buildDataTable($builder, $builder->getOptions()); + + if (null === $data && $builder->hasOption('query')) { + $data = $builder->getOption('query'); + } + if (null !== $data && !$data instanceof ProxyQueryInterface) { foreach ($this->registry->getProxyQueryFactories() as $proxyQueryFactory) { if ($proxyQueryFactory->supports($data)) { @@ -42,11 +52,7 @@ public function createNamedBuilder(string $name, string $type = DataTableType::c } } - $type = $this->registry->getType($type); - - $builder = $type->createBuilder($this, $name, $query, $options); - - $type->buildDataTable($builder, $builder->getOptions()); + $builder->setQuery($query); return $builder; } diff --git a/src/Type/ResolvedDataTableType.php b/src/Type/ResolvedDataTableType.php index 8db12642..4f06be41 100755 --- a/src/Type/ResolvedDataTableType.php +++ b/src/Type/ResolvedDataTableType.php @@ -10,7 +10,6 @@ use Kreyu\Bundle\DataTableBundle\DataTableInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; use Kreyu\Bundle\DataTableBundle\Extension\DataTableTypeExtensionInterface; -use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -52,7 +51,7 @@ public function getTypeExtensions(): array /** * @throws ExceptionInterface */ - public function createBuilder(DataTableFactoryInterface $factory, string $name, ?ProxyQueryInterface $query = null, array $options = []): DataTableBuilderInterface + public function createBuilder(DataTableFactoryInterface $factory, string $name, array $options = []): DataTableBuilderInterface { try { $options = $this->getOptionsResolver()->resolve($options); @@ -60,7 +59,7 @@ public function createBuilder(DataTableFactoryInterface $factory, string $name, throw new $exception(sprintf('An error has occurred resolving the options of the data table "%s": ', get_debug_type($this->getInnerType())).$exception->getMessage(), $exception->getCode(), $exception); } - return new DataTableBuilder($name, $this, $query, new EventDispatcher(), $options); + return new DataTableBuilder($name, $this, new EventDispatcher(), $options); } public function createView(DataTableInterface $dataTable): DataTableView diff --git a/src/Type/ResolvedDataTableTypeInterface.php b/src/Type/ResolvedDataTableTypeInterface.php index 720cb8d8..ac87d3d4 100755 --- a/src/Type/ResolvedDataTableTypeInterface.php +++ b/src/Type/ResolvedDataTableTypeInterface.php @@ -9,7 +9,6 @@ use Kreyu\Bundle\DataTableBundle\DataTableInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; use Kreyu\Bundle\DataTableBundle\Extension\DataTableTypeExtensionInterface; -use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; use Symfony\Component\OptionsResolver\OptionsResolver; interface ResolvedDataTableTypeInterface @@ -28,7 +27,7 @@ public function getTypeExtensions(): array; /** * @param array $options */ - public function createBuilder(DataTableFactoryInterface $factory, string $name, ?ProxyQueryInterface $query = null, array $options = []): DataTableBuilderInterface; + public function createBuilder(DataTableFactoryInterface $factory, string $name, array $options = []): DataTableBuilderInterface; public function createView(DataTableInterface $dataTable): DataTableView; diff --git a/tests/Unit/DataTableBuilderTest.php b/tests/Unit/DataTableBuilderTest.php index d9837dfc..fe5748ff 100644 --- a/tests/Unit/DataTableBuilderTest.php +++ b/tests/Unit/DataTableBuilderTest.php @@ -735,13 +735,16 @@ public function testGetDataTableResolvesExporters() private function createBuilder(): DataTableBuilder { - return new DataTableBuilder( + $builder = new DataTableBuilder( name: 'foo', type: $this->createStub(ResolvedDataTableTypeInterface::class), - query: $this->createStub(ProxyQueryInterface::class), dispatcher: $this->createStub(EventDispatcherInterface::class), options: [], ); + + $builder->setQuery(query: $this->createStub(ProxyQueryInterface::class)); + + return $builder; } private function createColumnFactory(): ColumnFactory From 59fe5fbe0208f29e8154e613f940c83611385aad Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Mon, 26 Jan 2026 13:07:17 +0100 Subject: [PATCH 2/8] Add empty value reference --- docs/src/docs/features/query.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/src/docs/features/query.md b/docs/src/docs/features/query.md index 5e5802de..971e9728 100644 --- a/docs/src/docs/features/query.md +++ b/docs/src/docs/features/query.md @@ -67,6 +67,12 @@ class ProductDataTableType extends AbstractDataTableType This allows you to define a default configuration that is fully overridable. +When a default value is defined, it is no longer mandatory to pass the query as the second argument of the `createDataTable` method. + +```php +$dataTable = $this->createDataTable(ProductDataTableType::class); +``` + ## Overriding the option When you define a default `query`, you can still override it when creating the data table in the controller: From 106cc3d9ad12145ceb6c55d720011af9b9fb7fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20St=C3=B6hr?= Date: Sun, 12 Apr 2026 18:11:01 +0200 Subject: [PATCH 3/8] Allow Symfony 8 (#254) * build(deps): allow symfony 8 * fixup! build(deps): allow symfony 8 * fixup! build(deps): allow symfony 8 --- composer.json | 21 +- src/DataCollector/DataTableDataCollector.php | 12 +- src/Request/HttpFoundationRequestHandler.php | 19 +- tests/ReflectionTrait.php | 9 +- .../Filter/DoctrineOrmFilterHandlerTest.php | 14 +- .../Orm/Fixtures/TestEntityManagerFactory.php | 4 + .../DataTableDataCollectorTest.php | 524 ++++++++++++++++++ 7 files changed, 577 insertions(+), 26 deletions(-) create mode 100644 tests/Unit/DataCollector/DataTableDataCollectorTest.php diff --git a/composer.json b/composer.json index 7ad9681f..2970177f 100755 --- a/composer.json +++ b/composer.json @@ -14,29 +14,30 @@ "license": "MIT", "require": { "php": "^8.1", - "symfony/framework-bundle": "^6.0|^7.0", - "symfony/translation": "^6.0|^7.0", - "symfony/form": "^6.0|^7.0", - "symfony/mime": "^6.0|^7.0", - "symfony/twig-bundle": "^6.0|^7.0", + "symfony/framework-bundle": "^6.0|^7.0|^8.0", + "symfony/translation": "^6.0|^7.0|^8.0", + "symfony/form": "^6.0|^7.0|^8.0", + "symfony/mime": "^6.0|^7.0|^8.0", + "symfony/twig-bundle": "^6.0|^7.0|^8.0", "twig/extra-bundle": "^3.6", "twig/intl-extra": "^3.6", - "symfony/validator": "^6.0|^7.0" + "symfony/validator": "^6.0|^7.0|^8.0" }, "require-dev": { "roave/security-advisories": "dev-latest", "friendsofphp/php-cs-fixer": "^3.49", "kubawerlos/php-cs-fixer-custom-fixers": "^3.11", "symfony/maker-bundle": "^1.48", - "symfony/security-core": "^6.2|^7.0", + "symfony/security-core": "^6.2|^7.0|^8.0", "phpoffice/phpspreadsheet": "^1.28", - "doctrine/orm": "^2.15", - "doctrine/doctrine-bundle": "^2.9", + "doctrine/orm": "^2.15|^3.0", + "doctrine/doctrine-bundle": "^2.9|^3.0", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^10.4", "dg/bypass-finals": "dev-master", "openspout/openspout": "^4.23", - "symfony/http-foundation": "^6.0|^7.0" + "symfony/http-foundation": "^6.0|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "autoload": { "psr-4": { diff --git a/src/DataCollector/DataTableDataCollector.php b/src/DataCollector/DataTableDataCollector.php index 992ee3cd..d28e0df4 100644 --- a/src/DataCollector/DataTableDataCollector.php +++ b/src/DataCollector/DataTableDataCollector.php @@ -36,11 +36,19 @@ public function __construct( } } - public function __sleep(): array + /** + * @return array + */ + public function __serialize(): array { $this->data = $this->cloneVar($this->data)->withMaxDepth($this->maxDepth); - return parent::__sleep(); + if (method_exists(parent::class, '__serialize')) { + return parent::__serialize(); + } + + // Symfony 7: __serialize() does not exist yet, replicate __sleep() behavior + return ['data' => $this->data]; } public static function getTemplate(): ?string diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index 29c9efbe..c0a78ead 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -48,7 +48,7 @@ private function filter(DataTableInterface $dataTable, Request $request): void $form = $dataTable->createFiltrationFormBuilder()->getForm(); - if ($data = $request->get($form->getName())) { + if ($data = $this->getRequestParameter($request, $form->getName())) { $form->submit($data); } @@ -105,7 +105,7 @@ private function personalize(DataTableInterface $dataTable, Request $request): v $form = $dataTable->createPersonalizationFormBuilder()->getForm(); - if ($data = $request->get($form->getName())) { + if ($data = $this->getRequestParameter($request, $form->getName())) { $form->submit($data); } @@ -122,7 +122,7 @@ private function export(DataTableInterface $dataTable, Request $request): void $form = $dataTable->createExportFormBuilder()->getForm(); - if ($data = $request->get($form->getName())) { + if ($data = $this->getRequestParameter($request, $form->getName())) { $form->submit($data); } @@ -131,6 +131,19 @@ private function export(DataTableInterface $dataTable, Request $request): void } } + private function getRequestParameter(Request $request, string $name): mixed + { + if ($request->query->has($name)) { + return $request->query->all()[$name]; + } + + if ($request->request->has($name)) { + return $request->request->all()[$name]; + } + + return null; + } + private function extractQueryParameter(Request $request, string $path): mixed { return $this->propertyAccessor->getValue($request->query->all(), $path); diff --git a/tests/ReflectionTrait.php b/tests/ReflectionTrait.php index db452ce3..27f67053 100644 --- a/tests/ReflectionTrait.php +++ b/tests/ReflectionTrait.php @@ -8,16 +8,11 @@ trait ReflectionTrait { private function getPrivatePropertyValue(object $object, string $property): mixed { - $reflection = new \ReflectionProperty($object, $property); - $reflection->setAccessible(true); - - return $reflection->getValue($object); + return (new \ReflectionProperty($object, $property))->getValue($object); } private function setPrivatePropertyValue(object $object, string $property, mixed $value): void { - $reflection = new \ReflectionProperty($object, $property); - $reflection->setAccessible(true); - $reflection->setValue($object, $value); + (new \ReflectionProperty($object, $property))->setValue($object, $value); } } diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php index f4cce4a4..7bc951f0 100644 --- a/tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php @@ -60,8 +60,10 @@ public function testItAppliesExpression(): void ($queryBuilder = $this->createQueryBuilderMock()) ->expects($this->once()) ->method('andWhere') - ->willReturnCallback(function (mixed $expression) { + ->willReturnCallback(function (mixed $expression) use (&$queryBuilder) { $this->assertEquals('expression', $expression); + + return $queryBuilder; }); $this->query->method('getQueryBuilder')->willReturn($queryBuilder); @@ -74,10 +76,12 @@ public function testItSetsParameterWithoutTypeSpecified(): void ($queryBuilder = $this->createQueryBuilderMock()) ->expects($this->once()) ->method('setParameter') - ->willReturnCallback(function ($name, $value, $type) { + ->willReturnCallback(function ($name, $value, $type) use (&$queryBuilder) { $this->assertEquals('foo', $name); $this->assertEquals('bar', $value); $this->assertNull($type); + + return $queryBuilder; }); $this->query->method('getQueryBuilder')->willReturn($queryBuilder); @@ -85,15 +89,17 @@ public function testItSetsParameterWithoutTypeSpecified(): void $this->createHandler(parameters: [new Parameter('foo', 'bar')])->handle($this->query, $this->data, $this->filter); } - public function testItSetsParameterWithTypeSpecified() + public function testItSetsParameterWithTypeSpecified(): void { ($queryBuilder = $this->createQueryBuilderMock()) ->expects($this->once()) ->method('setParameter') - ->willReturnCallback(function ($name, $value, $type) { + ->willReturnCallback(function ($name, $value, $type) use (&$queryBuilder) { $this->assertEquals('foo', $name); $this->assertEquals('bar', $value); $this->assertEquals('date_immutable', $type); + + return $queryBuilder; }); $this->query->method('getQueryBuilder')->willReturn($queryBuilder); diff --git a/tests/Unit/Bridge/Doctrine/Orm/Fixtures/TestEntityManagerFactory.php b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/TestEntityManagerFactory.php index f5f612db..7112268e 100644 --- a/tests/Unit/Bridge/Doctrine/Orm/Fixtures/TestEntityManagerFactory.php +++ b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/TestEntityManagerFactory.php @@ -21,6 +21,10 @@ public static function create(): EntityManagerInterface $config = ORMSetup::createAttributeMetadataConfiguration([], true); + if (PHP_VERSION_ID >= 80400 && method_exists($config, 'enableNativeLazyObjects')) { + $config->enableNativeLazyObjects(true); + } + $connection = DriverManager::getConnection([ 'driver' => 'pdo_sqlite', 'memory' => true, diff --git a/tests/Unit/DataCollector/DataTableDataCollectorTest.php b/tests/Unit/DataCollector/DataTableDataCollectorTest.php new file mode 100644 index 00000000..9f8758ce --- /dev/null +++ b/tests/Unit/DataCollector/DataTableDataCollectorTest.php @@ -0,0 +1,524 @@ +dataExtractor = $this->createMock(DataTableDataExtractorInterface::class); + $this->collector = new DataTableDataCollector($this->dataExtractor); + } + + public function testCollectDoesNothing(): void + { + $this->collector->collect(new Request(), new Response()); + + $this->assertSame([], $this->collector->getData()); + } + + public function testGetTemplate(): void + { + $this->assertSame( + '@KreyuDataTable/data_collector/template.html.twig', + DataTableDataCollector::getTemplate(), + ); + } + + public function testCollectDataTable(): void + { + $column = $this->createColumnMock('id'); + $filter = $this->createFilterMock('name'); + $action = $this->createActionMock('edit'); + $rowAction = $this->createActionMock('view'); + $batchAction = $this->createActionMock('delete'); + $exporter = $this->createExporterMock('csv'); + + $config = $this->createMock(DataTableConfigInterface::class); + $config->method('isPaginationEnabled')->willReturn(true); + + $dataTable = $this->createDataTableMock('users'); + $dataTable->method('getColumns')->willReturn(['id' => $column]); + $dataTable->method('getFilters')->willReturn(['name' => $filter]); + $dataTable->method('getActions')->willReturn(['edit' => $action]); + $dataTable->method('getRowActions')->willReturn(['view' => $rowAction]); + $dataTable->method('getBatchActions')->willReturn(['delete' => $batchAction]); + $dataTable->method('getExporters')->willReturn(['csv' => $exporter]); + $dataTable->method('getConfig')->willReturn($config); + + $this->dataExtractor->method('extractDataTableConfiguration')->willReturn(['type_class' => 'App\\DataTable']); + $this->dataExtractor->method('extractColumnConfiguration')->willReturn(['column_config' => true]); + $this->dataExtractor->method('extractFilterConfiguration')->willReturn(['filter_config' => true]); + $this->dataExtractor->method('extractActionConfiguration')->willReturnCallback( + fn (ActionInterface $a) => ['action_name' => $a->getName()], + ); + $this->dataExtractor->method('extractExporterConfiguration')->willReturn(['exporter_config' => true]); + + $this->collector->collectDataTable($dataTable); + + $data = $this->collector->getData(); + + $this->assertArrayHasKey('users', $data); + $this->assertSame('App\\DataTable', $data['users']['type_class']); + $this->assertSame(['column_config' => true], $data['users']['columns']['id']); + $this->assertSame(['filter_config' => true], $data['users']['filters']['name']); + $this->assertSame(['action_name' => 'edit'], $data['users']['actions']['edit']); + $this->assertSame(['action_name' => 'view'], $data['users']['row_actions']['view']); + $this->assertSame(['action_name' => 'delete'], $data['users']['batch_actions']['delete']); + $this->assertSame(['exporter_config' => true], $data['users']['exporters']['csv']); + } + + public function testCollectDataTableWithPaginationDisabledCollectsTotalCount(): void + { + $config = $this->createMock(DataTableConfigInterface::class); + $config->method('isPaginationEnabled')->willReturn(false); + + $dataTable = $this->createDataTableMock('products'); + $dataTable->method('getColumns')->willReturn([]); + $dataTable->method('getFilters')->willReturn([]); + $dataTable->method('getActions')->willReturn([]); + $dataTable->method('getRowActions')->willReturn([]); + $dataTable->method('getBatchActions')->willReturn([]); + $dataTable->method('getExporters')->willReturn([]); + $dataTable->method('getConfig')->willReturn($config); + $dataTable->method('getItems')->willReturn(new \ArrayIterator([1, 2, 3])); + + $this->dataExtractor->method('extractDataTableConfiguration')->willReturn([]); + + $this->collector->collectDataTable($dataTable); + + $data = $this->collector->getData(); + $this->assertSame(3, $data['products']['total_count']); + } + + public function testCollectDataTableView(): void + { + $this->initializeCollectorWithDataTable('orders'); + + $view = new DataTableView(); + $view->vars = ['foo' => 'bar', 'baz' => 'qux']; + + $dataTable = $this->createDataTableMock('orders'); + $this->dataExtractor->method('extractValueRows')->willReturn([['row1'], ['row2']]); + + $this->collector->collectDataTableView($dataTable, $view); + + $data = $this->collector->getData(); + $this->assertSame(['baz' => 'qux', 'foo' => 'bar'], $data['orders']['view_vars']); + $this->assertSame([['row1'], ['row2']], $data['orders']['value_rows']); + } + + public function testCollectColumnHeaderView(): void + { + $this->initializeCollectorWithDataTable('users', columns: ['email']); + + $column = $this->createColumnMock('email'); + $columnDataTable = $this->createDataTableMock('users'); + $column->method('getDataTable')->willReturn($columnDataTable); + + $headerView = new ColumnHeaderView(new HeaderRowView(new DataTableView())); + $headerView->vars = ['label' => 'Email', 'attr' => ['class' => 'email']]; + + $this->collector->collectColumnHeaderView($column, $headerView); + + $data = $this->collector->getData(); + $this->assertSame( + ['attr' => ['class' => 'email'], 'label' => 'Email'], + $data['users']['columns']['email']['header_view_vars'], + ); + } + + public function testCollectColumnValueViewSkipsNestedColumns(): void + { + $this->initializeCollectorWithDataTable('users', columns: ['tags']); + + $column = $this->createColumnMock('tags'); + $columnDataTable = $this->createDataTableMock('users'); + $column->method('getDataTable')->willReturn($columnDataTable); + + $dataTableView = new DataTableView(); + $parentRow = new ValueRowView($dataTableView, 0, ['id' => 1]); + $parentRow->origin = $parentRow; + + $valueView = new ColumnValueView($parentRow); + $valueView->vars = ['value' => 'test']; + + $this->collector->collectColumnValueView($column, $valueView); + + $data = $this->collector->getData(); + $this->assertArrayNotHasKey('value_view_vars', $data['users']['columns']['tags']); + } + + public function testCollectColumnValueViewCollectsTopLevelColumns(): void + { + $this->initializeCollectorWithDataTable('users', columns: ['name']); + + $column = $this->createColumnMock('name'); + $columnDataTable = $this->createDataTableMock('users'); + $column->method('getDataTable')->willReturn($columnDataTable); + + $dataTableView = new DataTableView(); + $parentRow = new ValueRowView($dataTableView, 0, ['id' => 1]); + + $valueView = new ColumnValueView($parentRow); + $valueView->vars = ['value' => 'John', 'attr' => []]; + + $this->collector->collectColumnValueView($column, $valueView); + + $data = $this->collector->getData(); + $this->assertSame( + ['attr' => [], 'value' => 'John'], + $data['users']['columns']['name']['value_view_vars'], + ); + } + + public function testCollectSortingData(): void + { + $this->initializeCollectorWithDataTable('users', columns: ['name', 'email']); + + $dataTable = $this->createDataTableMock('users'); + $dataTable->method('hasColumn')->willReturnCallback(fn (string $name) => in_array($name, ['name', 'email'])); + + $nameColumn = $this->createColumnMock('name'); + $nameDataTable = $this->createDataTableMock('users'); + $nameColumn->method('getDataTable')->willReturn($nameDataTable); + + $emailColumn = $this->createColumnMock('email'); + $emailDataTable = $this->createDataTableMock('users'); + $emailColumn->method('getDataTable')->willReturn($emailDataTable); + + $dataTable->method('getColumn')->willReturnMap([ + ['name', $nameColumn], + ['email', $emailColumn], + ]); + + $sortingData = new SortingData([ + new SortingColumnData('name', 'asc'), + new SortingColumnData('email', 'desc'), + ]); + + $this->collector->collectSortingData($dataTable, $sortingData); + + $data = $this->collector->getData(); + $this->assertSame('asc', $data['users']['columns']['name']['sort_direction']); + $this->assertSame('desc', $data['users']['columns']['email']['sort_direction']); + } + + public function testCollectSortingDataSkipsUnknownColumns(): void + { + $this->initializeCollectorWithDataTable('users'); + + $dataTable = $this->createDataTableMock('users'); + $dataTable->method('hasColumn')->willReturn(false); + + $sortingData = new SortingData([ + new SortingColumnData('nonexistent', 'asc'), + ]); + + $this->collector->collectSortingData($dataTable, $sortingData); + + $data = $this->collector->getData(); + $this->assertArrayNotHasKey('nonexistent', $data['users']['columns']); + } + + public function testCollectPaginationData(): void + { + $this->initializeCollectorWithDataTable('users'); + + $pagination = $this->createMock(PaginationInterface::class); + $pagination->method('getTotalItemCount')->willReturn(100); + + $dataTable = $this->createDataTableMock('users'); + $dataTable->method('getPagination')->willReturn($pagination); + + $paginationData = new PaginationData(3, 25); + + $this->collector->collectPaginationData($dataTable, $paginationData); + + $data = $this->collector->getData(); + $this->assertSame(3, $data['users']['page']); + $this->assertSame(25, $data['users']['per_page']); + $this->assertSame(100, $data['users']['total_count']); + } + + public function testCollectFilterView(): void + { + $this->initializeCollectorWithDataTable('users', filters: ['status']); + + $filter = $this->createFilterMock('status'); + $filterDataTable = $this->createDataTableMock('users'); + $filter->method('getDataTable')->willReturn($filterDataTable); + + $filterView = new FilterView(new DataTableView()); + $filterView->vars = ['label' => 'Status', 'attr' => []]; + + $this->collector->collectFilterView($filter, $filterView); + + $data = $this->collector->getData(); + $this->assertSame( + ['attr' => [], 'label' => 'Status'], + $data['users']['filters']['status']['view_vars'], + ); + } + + public function testCollectFiltrationData(): void + { + $this->initializeCollectorWithDataTable('users', filters: ['status', 'role']); + + $dataTable = $this->createDataTableMock('users'); + $dataTable->method('hasFilter')->willReturnCallback(fn (string $name) => in_array($name, ['status', 'role'])); + + $statusFilter = $this->createFilterMock('status'); + $statusDataTable = $this->createDataTableMock('users'); + $statusFilter->method('getDataTable')->willReturn($statusDataTable); + + $roleFilter = $this->createFilterMock('role'); + $roleDataTable = $this->createDataTableMock('users'); + $roleFilter->method('getDataTable')->willReturn($roleDataTable); + + $dataTable->method('getFilter')->willReturnMap([ + ['status', $statusFilter], + ['role', $roleFilter], + ]); + + $statusFilterData = new FilterData('active', Operator::Equals); + $roleFilterData = new FilterData('admin'); + + $filtrationData = new FiltrationData([ + 'status' => $statusFilterData, + 'role' => $roleFilterData, + ]); + + $this->collector->collectFiltrationData($dataTable, $filtrationData); + + $data = $this->collector->getData(); + $this->assertSame($statusFilterData, $data['users']['filters']['status']['data']); + $this->assertSame('Equals', $data['users']['filters']['status']['operator_label']); + $this->assertSame($roleFilterData, $data['users']['filters']['role']['data']); + $this->assertNull($data['users']['filters']['role']['operator_label']); + } + + public function testCollectFiltrationDataSkipsUnknownFilters(): void + { + $this->initializeCollectorWithDataTable('users'); + + $dataTable = $this->createDataTableMock('users'); + $dataTable->method('hasFilter')->willReturn(false); + + $filtrationData = new FiltrationData([ + 'nonexistent' => new FilterData('value'), + ]); + + $this->collector->collectFiltrationData($dataTable, $filtrationData); + + $data = $this->collector->getData(); + $this->assertArrayNotHasKey('nonexistent', $data['users']['filters']); + } + + public function testCollectActionViewForGlobalAction(): void + { + $this->initializeCollectorWithDataTable('users'); + + $this->assertActionViewCollected(ActionContext::Global, 'actions'); + } + + public function testCollectActionViewForRowAction(): void + { + $this->initializeCollectorWithDataTable('users'); + + $this->assertActionViewCollected(ActionContext::Row, 'row_actions'); + } + + public function testCollectActionViewForBatchAction(): void + { + $this->initializeCollectorWithDataTable('users'); + + $this->assertActionViewCollected(ActionContext::Batch, 'batch_actions'); + } + + public function testCollectDataTableMergesWithExistingData(): void + { + $config = $this->createMock(DataTableConfigInterface::class); + $config->method('isPaginationEnabled')->willReturn(true); + + $dataTable = $this->createDataTableMock('users'); + $dataTable->method('getColumns')->willReturn([]); + $dataTable->method('getFilters')->willReturn([]); + $dataTable->method('getActions')->willReturn([]); + $dataTable->method('getRowActions')->willReturn([]); + $dataTable->method('getBatchActions')->willReturn([]); + $dataTable->method('getExporters')->willReturn([]); + $dataTable->method('getConfig')->willReturn($config); + + $this->dataExtractor->method('extractDataTableConfiguration')->willReturn(['type_class' => 'App\\DataTable']); + + $this->collector->collectDataTable($dataTable); + $this->collector->collectDataTable($dataTable); + + $data = $this->collector->getData(); + $this->assertArrayHasKey('users', $data); + $this->assertSame('App\\DataTable', $data['users']['type_class']); + } + + public function testReset(): void + { + $this->initializeCollectorWithDataTable('users'); + + $this->assertNotEmpty($this->collector->getData()); + + $this->collector->reset(); + + $this->assertSame([], $this->collector->getData()); + } + + public function testDataSurvivesSerializationRoundtrip(): void + { + $this->initializeCollectorWithDataTable('users', columns: ['name']); + + $column = $this->createColumnMock('name'); + $columnDataTable = $this->createDataTableMock('users'); + $column->method('getDataTable')->willReturn($columnDataTable); + + $headerView = new ColumnHeaderView(new HeaderRowView(new DataTableView())); + $headerView->vars = ['label' => 'Name', 'attr' => []]; + $this->collector->collectColumnHeaderView($column, $headerView); + + $restoredCollector = unserialize(serialize($this->collector)); + + $this->assertInstanceOf(DataTableDataCollector::class, $restoredCollector); + $this->assertInstanceOf(Data::class, $restoredCollector->getData()); + } + + private function assertActionViewCollected(ActionContext $context, string $dataKey): void + { + $actionConfig = $this->createMock(ActionConfigInterface::class); + $actionConfig->method('getContext')->willReturn($context); + + $action = $this->createActionMock('test_action'); + $actionDataTable = $this->createDataTableMock('users'); + $action->method('getDataTable')->willReturn($actionDataTable); + $action->method('getConfig')->willReturn($actionConfig); + + $actionView = new ActionView(new DataTableView()); + $actionView->vars = ['label' => 'Test', 'attr' => []]; + + $this->collector->collectActionView($action, $actionView); + + $data = $this->collector->getData(); + $this->assertSame( + ['attr' => [], 'label' => 'Test'], + $data['users'][$dataKey]['test_action']['view_vars'], + ); + } + + private function initializeCollectorWithDataTable( + string $name, + array $columns = [], + array $filters = [], + ): void { + $config = $this->createMock(DataTableConfigInterface::class); + $config->method('isPaginationEnabled')->willReturn(true); + + $columnMocks = []; + foreach ($columns as $colName) { + $columnMocks[$colName] = $this->createColumnMock($colName); + } + + $filterMocks = []; + foreach ($filters as $filterName) { + $filterMocks[$filterName] = $this->createFilterMock($filterName); + } + + $dataTable = $this->createDataTableMock($name); + $dataTable->method('getColumns')->willReturn($columnMocks); + $dataTable->method('getFilters')->willReturn($filterMocks); + $dataTable->method('getActions')->willReturn([]); + $dataTable->method('getRowActions')->willReturn([]); + $dataTable->method('getBatchActions')->willReturn([]); + $dataTable->method('getExporters')->willReturn([]); + $dataTable->method('getConfig')->willReturn($config); + + $this->dataExtractor->method('extractDataTableConfiguration')->willReturn([]); + $this->dataExtractor->method('extractColumnConfiguration')->willReturn([]); + $this->dataExtractor->method('extractFilterConfiguration')->willReturn([]); + $this->dataExtractor->method('extractActionConfiguration')->willReturn([]); + $this->dataExtractor->method('extractExporterConfiguration')->willReturn([]); + + $this->collector->collectDataTable($dataTable); + } + + private function createDataTableMock(string $name): MockObject&DataTableInterface + { + $dataTable = $this->createMock(DataTableInterface::class); + $dataTable->method('getName')->willReturn($name); + + return $dataTable; + } + + private function createColumnMock(string $name): MockObject&ColumnInterface + { + $column = $this->createMock(ColumnInterface::class); + $column->method('getName')->willReturn($name); + + return $column; + } + + private function createFilterMock(string $name): MockObject&FilterInterface + { + $filter = $this->createMock(FilterInterface::class); + $filter->method('getName')->willReturn($name); + + return $filter; + } + + private function createActionMock(string $name): MockObject&ActionInterface + { + $action = $this->createMock(ActionInterface::class); + $action->method('getName')->willReturn($name); + + return $action; + } + + private function createExporterMock(string $name): MockObject&ExporterInterface + { + $exporter = $this->createMock(ExporterInterface::class); + $exporter->method('getName')->willReturn($name); + + return $exporter; + } +} From 7890242223fe6f7345d7f4e29f864c4800f0bcc7 Mon Sep 17 00:00:00 2001 From: alexandre-castelain Date: Sun, 12 Apr 2026 18:11:32 +0200 Subject: [PATCH 4/8] Remove useless import of the bootstrap modal (#253) --- assets/controllers/bootstrap/modal.js | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/controllers/bootstrap/modal.js b/assets/controllers/bootstrap/modal.js index 54185de8..475b1503 100644 --- a/assets/controllers/bootstrap/modal.js +++ b/assets/controllers/bootstrap/modal.js @@ -1,5 +1,4 @@ import { Controller } from '@hotwired/stimulus'; -import { Modal } from 'bootstrap'; /* stimulusFetch: 'lazy' */ export default class extends Controller { From 97d02ddda98c5c04b83610a057997ba3f6efc6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Roman=C3=AD?= Date: Sun, 12 Apr 2026 18:12:38 +0200 Subject: [PATCH 5/8] Update DataTableExtension.php (#249) Avoid deprecated log message --- src/Twig/DataTableExtension.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Twig/DataTableExtension.php b/src/Twig/DataTableExtension.php index 58005a87..43975a3b 100755 --- a/src/Twig/DataTableExtension.php +++ b/src/Twig/DataTableExtension.php @@ -17,6 +17,7 @@ use Kreyu\Bundle\DataTableBundle\ValueRowView; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; +use Twig\DeprecatedCallableInfo; use Twig\Environment; use Twig\Error\Error as TwigException; use Twig\Error\RuntimeError; @@ -71,7 +72,7 @@ public function getFunctions(): array $functions[] = new TwigFunction('data_table_form_aware', $this->renderDataTableFormAware(...), [ 'needs_environment' => true, 'is_safe' => ['html'], - 'deprecated' => true, + 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.15'), ]); return $functions; From 2ace2d9efd09de17c973d9a84e864c29fcd177a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Wr=C3=B3blewski?= Date: Sun, 12 Apr 2026 18:22:02 +0200 Subject: [PATCH 6/8] Add info about further development to README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 1bb01f6a..2855ee63 100755 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@

+> [!CAUTION] +> This package is looking for new maintainers or forks with continued development. +> For maintainer role, please contact via e-mail: kontakt@swroblewski.pl +> Links to the forks can be put into the README of this repo. ## About From be19728e1f6bf2c8bd0b16904542150cbc2ef5c7 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Fri, 17 Apr 2026 14:41:40 +0200 Subject: [PATCH 7/8] Replace query option with createQuery() hook on DataTableType --- docs/src/docs/features/query.md | 162 ++++++++++++------ ...esolvedDataTableTypeDataCollectorProxy.php | 5 + src/DataTableFactory.php | 10 +- src/Type/AbstractDataTableType.php | 5 + src/Type/DataTableType.php | 5 + src/Type/DataTableTypeInterface.php | 5 + src/Type/ResolvedDataTableType.php | 5 + src/Type/ResolvedDataTableTypeInterface.php | 5 + .../Type/CreateQueryChildDataTableType.php | 15 ++ .../Type/CreateQueryDataTableType.php | 16 ++ tests/Unit/DataTableFactoryTest.php | 71 +++++++- 11 files changed, 241 insertions(+), 63 deletions(-) create mode 100644 tests/Fixtures/DataTable/Type/CreateQueryChildDataTableType.php create mode 100644 tests/Fixtures/DataTable/Type/CreateQueryDataTableType.php diff --git a/docs/src/docs/features/query.md b/docs/src/docs/features/query.md index 971e9728..afca886d 100644 --- a/docs/src/docs/features/query.md +++ b/docs/src/docs/features/query.md @@ -1,16 +1,12 @@ # Query -The data table requires a query to fetch the data. This query can be passed directly to the factory or defined as a default option in the data table type. +A data table needs a query to fetch its rows. You can either pass it from the controller or let the data table type build a default one. [[toc]] -## Ways to handle the query +## Passing the query from the controller -There are several ways to pass the query to a DataTable. - -### Passing the query to the factory - -The query can be passed directly to the factory in the controller: +The classic way: the controller provides the query as the second argument of `createDataTable()`. ```php use App\DataTable\Type\ProductDataTableType; @@ -22,13 +18,16 @@ use Symfony\Component\HttpFoundation\Response; class ProductController extends AbstractController { use DataTableFactoryAwareTrait; - - public function index(Request $request): Response + + public function index(Request $request, ProductRepository $repository): Response { - // $query can be a QueryBuilder, an array, etc. - $dataTable = $this->createDataTable(ProductDataTableType::class, $query); + $dataTable = $this->createDataTable( + ProductDataTableType::class, + $repository->createQueryBuilder('p'), + ); + $dataTable->handleRequest($request); - + return $this->render('product/index.html.twig', [ 'products' => $dataTable->createView(), ]); @@ -36,86 +35,139 @@ class ProductController extends AbstractController } ``` -### Defining a default value - -You can provide a default value for the `query` option in your DataTable type. This avoids having to recreate the query builder every time you create the DataTable. +Any value that a registered `ProxyQueryFactoryInterface` supports works (Doctrine `QueryBuilder`, array, etc.). -Similar to how Symfony forms allow initializing `data` when no data is provided, you can initialize the `query` option. +## Defining a default query in the type -To do this, add the `query` option to your DataTable type: +When the controller does not need to customize the query, the type can build it via `createQuery()`: ```php -use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; -use Symfony\Component\OptionsResolver\OptionsResolver; use App\Repository\ProductRepository; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; -class ProductDataTableType extends AbstractDataTableType +class ProductDataTableType extends AbstractDataTableType { public function __construct( - private ProductRepository $productRepository, + private ProductRepository $repository, ) { } - public function configureOptions(OptionsResolver $resolver): void + public function createQuery(array $options): mixed { - $resolver->setDefaults([ - 'query' => $this->productRepository->createQueryBuilder('p'), - ]); + return $this->repository->createQueryBuilder('p'); } } ``` -This allows you to define a default configuration that is fully overridable. - -When a default value is defined, it is no longer mandatory to pass the query as the second argument of the `createDataTable` method. +The controller can then omit the second argument: ```php $dataTable = $this->createDataTable(ProductDataTableType::class); ``` -## Overriding the option +`createQuery()` is called **once per data table creation**, so returning a fresh `QueryBuilder` instance each time keeps data tables isolated from one another — mutations from filters, sorting, or pagination on one data table never leak into the next. -When you define a default `query`, you can still override it when creating the data table in the controller: +The method receives the resolved options, so a type can parameterize the query through its own options: ```php -$dataTable = $this->createDataTable(ProductDataTableType::class, $customQuery); -``` +public function configureOptions(OptionsResolver $resolver): void +{ + $resolver + ->setDefault('active_only', false) + ->setAllowedTypes('active_only', 'bool'); +} + +public function createQuery(array $options): mixed +{ + $qb = $this->repository->createQueryBuilder('p'); -Or by passing it in the options: + if ($options['active_only']) { + $qb->andWhere('p.active = true'); + } + + return $qb; +} +``` ```php $dataTable = $this->createDataTable(ProductDataTableType::class, null, [ - 'query' => $customQuery, + 'active_only' => true, ]); ``` -### Extending the default query +## Overriding from the controller -If you want to reuse the default query defined in the `ProductDataTableType` and add a condition from the controller, you can use the `OptionsResolver` normalizer: +An explicit second argument always wins over `createQuery()`. This fully replaces what the type would have returned — the controller owns the query in this case: ```php -use App\DataTable\Type\ProductDataTableType; -use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\OptionsResolver\Options; -use Doctrine\ORM\QueryBuilder; +$dataTable = $this->createDataTable(ProductDataTableType::class, $customQueryBuilder); +``` -class ProductController extends AbstractController +There is no mechanism to "grab the type's default query and add conditions on top" — the controller does not have access to the already-built `QueryBuilder` of the type. If you need to extend the default, choose one of the patterns below. + +### Option 1 — Parameterize through options + +When the controller should influence *how* the type builds its query, expose a type option and branch inside `createQuery()`. This is the canonical way to reuse + extend: + +```php +public function configureOptions(OptionsResolver $resolver): void { - use DataTableFactoryAwareTrait; - - public function index() + $resolver + ->setDefault('active_only', false) + ->setAllowedTypes('active_only', 'bool'); +} + +public function createQuery(array $options): mixed +{ + $qb = $this->repository->createQueryBuilder('p'); + + if ($options['active_only']) { + $qb->andWhere('p.active = true'); + } + + return $qb; +} +``` + +```php +$dataTable = $this->createDataTable(ProductDataTableType::class, null, [ + 'active_only' => true, +]); +``` + +### Option 2 — Share a builder in the repository + +When the same base query is needed in several places (type, controller, other services), expose it from the repository and reuse it: + +```php +class ProductRepository extends ServiceEntityRepository +{ + public function createBaseQueryBuilder(): QueryBuilder { - $dataTable = $this->createDataTable(ProductDataTableType::class, null, [ - 'query' => function (Options $options, $query) { - if ($query instanceof QueryBuilder) { - $query->andWhere('p.active = :active') - ->setParameter('active', true); - } - - return $query; - }, - ]); + return $this->createQueryBuilder('p') + ->andWhere('p.deletedAt IS NULL'); } } ``` + +```php +// In the type: +public function createQuery(array $options): mixed +{ + return $this->repository->createBaseQueryBuilder(); +} +``` + +```php +// In the controller, reusing the same base and adding a condition: +$qb = $repository->createBaseQueryBuilder() + ->andWhere('p.stock > 0'); + +$dataTable = $this->createDataTable(ProductDataTableType::class, $qb); +``` + +The controller ends up overriding the default (option 1 is not used), but it starts from the same base the type would have returned. + +## Parent types + +When a type extends another type (via `getParent()`), `createQuery()` uses the child's return value, and falls back to the parent if the child returns `null`. This mirrors how Symfony Forms resolves `empty_data` and similar defaults. diff --git a/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php b/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php index 39d9d88a..221b304b 100644 --- a/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php +++ b/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php @@ -60,6 +60,11 @@ public function createExportView(DataTableInterface $dataTable): DataTableView return $this->proxiedType->createExportView($dataTable); } + public function createQuery(array $options): mixed + { + return $this->proxiedType->createQuery($options); + } + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void { $this->proxiedType->buildDataTable($builder, $options); diff --git a/src/DataTableFactory.php b/src/DataTableFactory.php index 9d1ce947..d760868e 100755 --- a/src/DataTableFactory.php +++ b/src/DataTableFactory.php @@ -31,17 +31,13 @@ public function createBuilder(string $type = DataTableType::class, mixed $data = public function createNamedBuilder(string $name, string $type = DataTableType::class, mixed $data = null, array $options = []): DataTableBuilderInterface { - $query = $data; - $type = $this->registry->getType($type); $builder = $type->createBuilder($this, $name, $options); - $type->buildDataTable($builder, $builder->getOptions()); + $data ??= $type->createQuery($builder->getOptions()); - if (null === $data && $builder->hasOption('query')) { - $data = $builder->getOption('query'); - } + $query = $data; if (null !== $data && !$data instanceof ProxyQueryInterface) { foreach ($this->registry->getProxyQueryFactories() as $proxyQueryFactory) { @@ -54,6 +50,8 @@ public function createNamedBuilder(string $name, string $type = DataTableType::c $builder->setQuery($query); + $type->buildDataTable($builder, $builder->getOptions()); + return $builder; } } diff --git a/src/Type/AbstractDataTableType.php b/src/Type/AbstractDataTableType.php index 58bfb4ea..81750bcc 100755 --- a/src/Type/AbstractDataTableType.php +++ b/src/Type/AbstractDataTableType.php @@ -24,6 +24,11 @@ public function buildExportView(DataTableView $view, DataTableInterface $dataTab { } + public function createQuery(array $options): mixed + { + return null; + } + public function configureOptions(OptionsResolver $resolver): void { } diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index e76e935a..390f15ff 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -151,6 +151,11 @@ public function buildExportView(DataTableView $view, DataTableInterface $dataTab $view->valueRows = new RowIterator(fn () => $this->createExportValueRowsViews($view, $dataTable, $columns)); } + public function createQuery(array $options): mixed + { + return null; + } + public function configureOptions(OptionsResolver $resolver): void { $resolver diff --git a/src/Type/DataTableTypeInterface.php b/src/Type/DataTableTypeInterface.php index 5e7d87e5..db039dd6 100755 --- a/src/Type/DataTableTypeInterface.php +++ b/src/Type/DataTableTypeInterface.php @@ -26,6 +26,11 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar */ public function buildExportView(DataTableView $view, DataTableInterface $dataTable, array $options): void; + /** + * @param array $options + */ + public function createQuery(array $options): mixed; + public function configureOptions(OptionsResolver $resolver): void; public function getName(): string; diff --git a/src/Type/ResolvedDataTableType.php b/src/Type/ResolvedDataTableType.php index 4f06be41..8d1af016 100755 --- a/src/Type/ResolvedDataTableType.php +++ b/src/Type/ResolvedDataTableType.php @@ -72,6 +72,11 @@ public function createExportView(DataTableInterface $dataTable): DataTableView return new DataTableView(); } + public function createQuery(array $options): mixed + { + return $this->innerType->createQuery($options) ?? $this->parent?->createQuery($options); + } + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void { $this->parent?->buildDataTable($builder, $options); diff --git a/src/Type/ResolvedDataTableTypeInterface.php b/src/Type/ResolvedDataTableTypeInterface.php index ac87d3d4..bd0a78e9 100755 --- a/src/Type/ResolvedDataTableTypeInterface.php +++ b/src/Type/ResolvedDataTableTypeInterface.php @@ -33,6 +33,11 @@ public function createView(DataTableInterface $dataTable): DataTableView; public function createExportView(DataTableInterface $dataTable): DataTableView; + /** + * @param array $options + */ + public function createQuery(array $options): mixed; + /** * @param array $options */ diff --git a/tests/Fixtures/DataTable/Type/CreateQueryChildDataTableType.php b/tests/Fixtures/DataTable/Type/CreateQueryChildDataTableType.php new file mode 100644 index 00000000..c82c6dca --- /dev/null +++ b/tests/Fixtures/DataTable/Type/CreateQueryChildDataTableType.php @@ -0,0 +1,15 @@ +assertSame($data, $builder->getQuery()); } + public function testCreateNamedBuilderUsesTypeCreateQuery() + { + $builder = $this->createFactory(proxyQueryFactories: [new CustomProxyQueryFactory()]) + ->createNamedBuilder('name', CreateQueryDataTableType::class); + + $this->assertInstanceOf(CustomProxyQuery::class, $builder->getQuery()); + } + + public function testCreateNamedBuilderDataOverridesCreateQuery() + { + $explicitData = new CustomProxyQuery(); + + $builder = $this->createFactory(proxyQueryFactories: [new CustomProxyQueryFactory()]) + ->createNamedBuilder('name', CreateQueryDataTableType::class, data: $explicitData); + + $this->assertSame($explicitData, $builder->getQuery()); + } + + public function testCreateQueryChainFallsBackToParent() + { + $builder = $this->createFactory( + types: [ + new DataTableType(), + new CreateQueryDataTableType(), + new CreateQueryChildDataTableType(), + ], + proxyQueryFactories: [new CustomProxyQueryFactory()], + )->createNamedBuilder('name', CreateQueryChildDataTableType::class); + + $this->assertInstanceOf(CustomProxyQuery::class, $builder->getQuery()); + } + + public function testBuildDataTableSeesQueryFromCreateQuery() + { + $seen = null; + + $type = new class($seen) extends AbstractDataTableType { + public function __construct( + private mixed &$seen, + ) { + } + + public function createQuery(array $options): mixed + { + return new CustomQuery(); + } + + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $this->seen = $builder->getQuery(); + } + }; + + $this->createFactory( + types: [new DataTableType(), $type], + proxyQueryFactories: [new CustomProxyQueryFactory()], + )->createNamedBuilder('name', $type::class); + + $this->assertInstanceOf(CustomProxyQuery::class, $seen); + } + public function testCreateBuilderUsesDataTableName() { $builder = $this->createFactory()->createBuilder(SimpleDataTableType::class); @@ -89,13 +154,15 @@ public function testCreateNamed() $this->assertInstanceOf(ConfigurableDataTableType::class, $dataTable->getConfig()->getType()->getInnerType()); } - private function createFactory(array $proxyQueryFactories = []): DataTableFactory + private function createFactory(?array $types = null, array $proxyQueryFactories = []): DataTableFactory { $registry = new DataTableRegistry( - types: [ + types: $types ?? [ new DataTableType(), new SimpleDataTableType(), new ConfigurableDataTableType(), + new CreateQueryDataTableType(), + new CreateQueryChildDataTableType(), ], typeExtensions: [], proxyQueryFactories: $proxyQueryFactories, From ea00ef77a8d0e2e68f9ca4308289267578c9944c Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Fri, 17 Apr 2026 14:45:45 +0200 Subject: [PATCH 8/8] Fix phpstan warning about unread property in anonymous test type --- tests/Unit/DataTableFactoryTest.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Unit/DataTableFactoryTest.php b/tests/Unit/DataTableFactoryTest.php index fd896141..bea00df1 100644 --- a/tests/Unit/DataTableFactoryTest.php +++ b/tests/Unit/DataTableFactoryTest.php @@ -85,11 +85,12 @@ public function testCreateQueryChainFallsBackToParent() public function testBuildDataTableSeesQueryFromCreateQuery() { - $seen = null; + $spy = new \stdClass(); + $spy->seen = null; - $type = new class($seen) extends AbstractDataTableType { + $type = new class($spy) extends AbstractDataTableType { public function __construct( - private mixed &$seen, + private \stdClass $spy, ) { } @@ -100,7 +101,7 @@ public function createQuery(array $options): mixed public function buildDataTable(DataTableBuilderInterface $builder, array $options): void { - $this->seen = $builder->getQuery(); + $this->spy->seen = $builder->getQuery(); } }; @@ -109,7 +110,7 @@ public function buildDataTable(DataTableBuilderInterface $builder, array $option proxyQueryFactories: [new CustomProxyQueryFactory()], )->createNamedBuilder('name', $type::class); - $this->assertInstanceOf(CustomProxyQuery::class, $seen); + $this->assertInstanceOf(CustomProxyQuery::class, $spy->seen); } public function testCreateBuilderUsesDataTableName()