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 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 { 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/docs/src/docs/features/query.md b/docs/src/docs/features/query.md new file mode 100644 index 00000000..afca886d --- /dev/null +++ b/docs/src/docs/features/query.md @@ -0,0 +1,173 @@ +# Query + +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]] + +## Passing the query from the controller + +The classic way: the controller provides the query as the second argument of `createDataTable()`. + +```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, ProductRepository $repository): Response + { + $dataTable = $this->createDataTable( + ProductDataTableType::class, + $repository->createQueryBuilder('p'), + ); + + $dataTable->handleRequest($request); + + return $this->render('product/index.html.twig', [ + 'products' => $dataTable->createView(), + ]); + } +} +``` + +Any value that a registered `ProxyQueryFactoryInterface` supports works (Doctrine `QueryBuilder`, array, etc.). + +## Defining a default query in the type + +When the controller does not need to customize the query, the type can build it via `createQuery()`: + +```php +use App\Repository\ProductRepository; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + private ProductRepository $repository, + ) { + } + + public function createQuery(array $options): mixed + { + return $this->repository->createQueryBuilder('p'); + } +} +``` + +The controller can then omit the second argument: + +```php +$dataTable = $this->createDataTable(ProductDataTableType::class); +``` + +`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. + +The method receives the resolved options, so a type can parameterize the query through its own options: + +```php +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'); + + if ($options['active_only']) { + $qb->andWhere('p.active = true'); + } + + return $qb; +} +``` + +```php +$dataTable = $this->createDataTable(ProductDataTableType::class, null, [ + 'active_only' => true, +]); +``` + +## Overriding from the controller + +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 +$dataTable = $this->createDataTable(ProductDataTableType::class, $customQueryBuilder); +``` + +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 +{ + $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 + { + 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/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/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/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php b/src/DataCollector/Proxy/ResolvedDataTableTypeDataCollectorProxy.php index 1637f70c..221b304b 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); @@ -61,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/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..d760868e 100755 --- a/src/DataTableFactory.php +++ b/src/DataTableFactory.php @@ -31,6 +31,12 @@ public function createBuilder(string $type = DataTableType::class, mixed $data = public function createNamedBuilder(string $name, string $type = DataTableType::class, mixed $data = null, array $options = []): DataTableBuilderInterface { + $type = $this->registry->getType($type); + + $builder = $type->createBuilder($this, $name, $options); + + $data ??= $type->createQuery($builder->getOptions()); + $query = $data; if (null !== $data && !$data instanceof ProxyQueryInterface) { @@ -42,9 +48,7 @@ public function createNamedBuilder(string $name, string $type = DataTableType::c } } - $type = $this->registry->getType($type); - - $builder = $type->createBuilder($this, $name, $query, $options); + $builder->setQuery($query); $type->buildDataTable($builder, $builder->getOptions()); 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/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; 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 8db12642..8d1af016 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 @@ -73,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 720cb8d8..bd0a78e9 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,12 +27,17 @@ 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; 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 @@ +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; + } +} 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 diff --git a/tests/Unit/DataTableFactoryTest.php b/tests/Unit/DataTableFactoryTest.php index b96e2aca..bea00df1 100644 --- a/tests/Unit/DataTableFactoryTest.php +++ b/tests/Unit/DataTableFactoryTest.php @@ -4,13 +4,17 @@ namespace Kreyu\Bundle\DataTableBundle\Tests\Unit; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; use Kreyu\Bundle\DataTableBundle\DataTableFactory; use Kreyu\Bundle\DataTableBundle\DataTableRegistry; use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\CustomQuery; use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\DataTable\Query\CustomProxyQuery; use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\DataTable\Query\CustomProxyQueryFactory; use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\DataTable\Type\ConfigurableDataTableType; +use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\DataTable\Type\CreateQueryChildDataTableType; +use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\DataTable\Type\CreateQueryDataTableType; use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\DataTable\Type\SimpleDataTableType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; use Kreyu\Bundle\DataTableBundle\Type\DataTableType; use Kreyu\Bundle\DataTableBundle\Type\ResolvedDataTableTypeFactory; use PHPUnit\Framework\TestCase; @@ -47,6 +51,68 @@ public function testCreateNamedBuilderWithProxyQueryData() $this->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() + { + $spy = new \stdClass(); + $spy->seen = null; + + $type = new class($spy) extends AbstractDataTableType { + public function __construct( + private \stdClass $spy, + ) { + } + + public function createQuery(array $options): mixed + { + return new CustomQuery(); + } + + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $this->spy->seen = $builder->getQuery(); + } + }; + + $this->createFactory( + types: [new DataTableType(), $type], + proxyQueryFactories: [new CustomProxyQueryFactory()], + )->createNamedBuilder('name', $type::class); + + $this->assertInstanceOf(CustomProxyQuery::class, $spy->seen); + } + public function testCreateBuilderUsesDataTableName() { $builder = $this->createFactory()->createBuilder(SimpleDataTableType::class); @@ -89,13 +155,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,