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