From 5ca932e7f400ebc128229e5fe64ee477469a3e4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 11:44:44 +0000 Subject: [PATCH] Optimize code across 5 areas with PHPBench benchmarks Cycle 1 - GroupActionCollection: Replace redundant count() ID generation with auto-increment counter; merge 3 foreach loops into 2 in addToFormContainer() (buttons + options in one pass, sub-actions separate). Cycle 2 - ArrayDataSource: Simplify sort flatten with array_merge(...) spread operator; deduplicate DateTime conversion in applyFilterDateRange() to convert row value once before from/to checks. Cycle 3 - ArraysHelper: Simplify testEmpty() by replacing truthy check + in_array([0,'0',false]) with single !== null && !== '' condition. Cycle 4 - Row: Cache type-dispatching closure in constructor via match() to avoid repeated instanceof chain on every getValue() call. Cycle 5 - DateTimeHelper: Extract default formats as class constant to avoid array recreation on every fromString() call. Datagrid::getColumns() uses array_filter+array_keys instead of manual loop for defaultHide. Added PHPBench infrastructure with 5 benchmark classes covering all optimized areas, Makefile targets (bench, bench-baseline, bench-compare). All 29 tests pass, PHPStan level 8 clean, code style clean. https://claude.ai/code/session_014wj5NNHPqbkW4LnnyXX2sB --- Makefile | 11 +- benchmarks/ArrayDataSourceBench.php | 223 ++++++++++++++++++++++ benchmarks/ArraysHelperBench.php | 99 ++++++++++ benchmarks/DateTimeHelperBench.php | 148 ++++++++++++++ benchmarks/GroupActionCollectionBench.php | 162 ++++++++++++++++ benchmarks/RowValueAccessBench.php | 161 ++++++++++++++++ composer.json | 1 + phpbench.json | 9 + src/DataSource/ArrayDataSource.php | 58 ++---- src/Datagrid.php | 11 +- src/GroupAction/GroupActionCollection.php | 44 ++--- src/Row.php | 72 +++---- src/Utils/ArraysHelper.php | 10 +- src/Utils/DateTimeHelper.php | 20 +- 14 files changed, 901 insertions(+), 128 deletions(-) create mode 100644 benchmarks/ArrayDataSourceBench.php create mode 100644 benchmarks/ArraysHelperBench.php create mode 100644 benchmarks/DateTimeHelperBench.php create mode 100644 benchmarks/GroupActionCollectionBench.php create mode 100644 benchmarks/RowValueAccessBench.php create mode 100644 phpbench.json diff --git a/Makefile b/Makefile index bc26f69fb..842eec15f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install qa cs csf phpstan tests coverage +.PHONY: install qa cs csf phpstan tests coverage bench bench-baseline bench-compare install: composer update @@ -27,3 +27,12 @@ ifdef GITHUB_ACTION else vendor/bin/tester -s -p php --colors 1 -C --coverage coverage.html --coverage-src src tests/Cases endif + +bench: + vendor/bin/phpbench run --report=aggregate + +bench-baseline: + vendor/bin/phpbench run --tag=baseline --report=aggregate + +bench-compare: + vendor/bin/phpbench run --ref=baseline --report=aggregate diff --git a/benchmarks/ArrayDataSourceBench.php b/benchmarks/ArrayDataSourceBench.php new file mode 100644 index 000000000..54efe71ff --- /dev/null +++ b/benchmarks/ArrayDataSourceBench.php @@ -0,0 +1,223 @@ +sortGroupedData as $i) { + foreach ($i as $item) { + $dataSource[] = $item; + } + } + } + + /** + * Optimized sort flatten with array_merge spread + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpSortData')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchSortFlattenArrayMerge(array $params): void + { + $dataSource = array_merge(...array_values($this->sortGroupedData)); + } + + /** + * Original date range filter with duplicated DateTime conversion + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpDateRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchDateRangeFilterDuplicated(array $params): void + { + $dateFrom = new DateTime('2024-01-01'); + $dateTo = new DateTime('2024-12-31'); + + foreach ($this->dateRows as $row) { + $rowValue = $row['date']; + + // Duplicated conversion (original pattern) + // "from" check + if (!($rowValue instanceof DateTime)) { + $rowValue = new DateTime($rowValue); + } + + if ($rowValue->getTimestamp() < $dateFrom->getTimestamp()) { + continue; + } + + // "to" check — re-read and re-convert (original bug) + $rowValue2 = $row['date']; + + if (!($rowValue2 instanceof DateTime)) { + $rowValue2 = new DateTime($rowValue2); + } + + if ($rowValue2->getTimestamp() > $dateTo->getTimestamp()) { + continue; + } + } + } + + /** + * Optimized date range filter with single DateTime conversion + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpDateRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchDateRangeFilterSingle(array $params): void + { + $dateFrom = new DateTime('2024-01-01'); + $dateTo = new DateTime('2024-12-31'); + + foreach ($this->dateRows as $row) { + $rowValue = $row['date']; + + // Single conversion (optimized pattern) + if (!($rowValue instanceof DateTime)) { + $rowValue = new DateTime($rowValue); + } + + if ($rowValue->getTimestamp() < $dateFrom->getTimestamp()) { + continue; + } + + if ($rowValue->getTimestamp() > $dateTo->getTimestamp()) { + continue; + } + } + } + + /** + * Original sort key extraction with string cast and DateTimeInterface check + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(200)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpDateRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchSortWithGrouping(array $params): void + { + $data = []; + + foreach ($this->dateRows as $item) { + $value = $item['name']; + $sortBy = $value instanceof DateTimeInterface ? $value->format('Y-m-d H:i:s') : (string) $value; + $data[$sortBy][] = $item; + } + + ksort($data, SORT_LOCALE_STRING); + + $dataSource = []; + + foreach ($data as $i) { + foreach ($i as $item) { + $dataSource[] = $item; + } + } + } + + /** + * Optimized sort with array_merge spread for flattening + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(200)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpDateRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchSortWithArrayMerge(array $params): void + { + $data = []; + + foreach ($this->dateRows as $item) { + $value = $item['name']; + $sortBy = $value instanceof DateTimeInterface ? $value->format('Y-m-d H:i:s') : (string) $value; + $data[$sortBy][] = $item; + } + + ksort($data, SORT_LOCALE_STRING); + + $dataSource = $data !== [] ? array_merge(...array_values($data)) : []; + } + + /** + * @return array + */ + public function provideRowCounts(): array + { + return [ + '50 rows' => ['row_count' => 50], + '200 rows' => ['row_count' => 200], + '1000 rows' => ['row_count' => 1000], + ]; + } + + /** + * @param array{row_count: int} $params + */ + public function setUpSortData(array $params): void + { + $this->sortGroupedData = []; + $names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank']; + + for ($i = 0; $i < $params['row_count']; $i++) { + $name = $names[$i % count($names)]; + $this->sortGroupedData[$name][] = ['id' => $i, 'name' => $name, 'age' => rand(18, 80)]; + } + } + + /** + * @param array{row_count: int} $params + */ + public function setUpDateRows(array $params): void + { + $this->dateRows = []; + + for ($i = 0; $i < $params['row_count']; $i++) { + $this->dateRows[] = [ + 'id' => $i, + 'name' => 'Item ' . $i, + 'date' => '2024-' . str_pad((string) (($i % 12) + 1), 2, '0', STR_PAD_LEFT) . '-15', + ]; + } + } + +} diff --git a/benchmarks/ArraysHelperBench.php b/benchmarks/ArraysHelperBench.php new file mode 100644 index 000000000..f1d52aaba --- /dev/null +++ b/benchmarks/ArraysHelperBench.php @@ -0,0 +1,99 @@ +} $params + */ + #[Bench\Revs(5000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideArrays')] + public function benchTestEmptyOriginal(array $params): void + { + $this->testEmptyOriginal($params['data']); + } + + /** + * Optimized testEmpty implementation + * + * @param array{data: array} $params + */ + #[Bench\Revs(5000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideArrays')] + public function benchTestEmptyOptimized(array $params): void + { + $this->testEmptyOptimized($params['data']); + } + + /** + * @return array}> + */ + public function provideArrays(): array + { + return [ + 'empty_strings' => ['data' => ['', '', '', null, '', null, '', '']], + 'with_zero' => ['data' => [0, '', null, '0', false, '', null]], + 'nested_empty' => ['data' => [['', null], ['', [null, '']], '']], + 'nested_with_value' => ['data' => [['', null], ['', [null, 'hello']], '']], + 'all_truthy' => ['data' => ['a', 'b', 'c', 1, 2, 3, true]], + 'large_empty' => ['data' => array_fill(0, 100, '')], + 'large_mixed' => ['data' => array_merge(array_fill(0, 99, ''), ['value'])], + ]; + } + + /** + * Original implementation + */ + private function testEmptyOriginal(iterable $array): bool + { + foreach ($array as $value) { + if (is_array($value)) { + if (!$this->testEmptyOriginal($value)) { + return false; + } + } else { + if ($value) { + return false; + } + + if (in_array($value, [0, '0', false], true)) { + return false; + } + } + } + + return true; + } + + /** + * Optimized implementation + */ + private function testEmptyOptimized(iterable $array): bool + { + foreach ($array as $value) { + if (is_array($value)) { + if (!$this->testEmptyOptimized($value)) { + return false; + } + } elseif ($value !== null && $value !== '') { + return false; + } + } + + return true; + } + +} diff --git a/benchmarks/DateTimeHelperBench.php b/benchmarks/DateTimeHelperBench.php new file mode 100644 index 000000000..ad88354ef --- /dev/null +++ b/benchmarks/DateTimeHelperBench.php @@ -0,0 +1,148 @@ +} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideDateValues')] + public function benchFromStringOriginal(array $params): void + { + $this->fromStringOriginal($params['value'], $params['custom_formats']); + } + + /** + * Optimized: constant default formats with conditional merge + * + * @param array{value: string, custom_formats: array} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideDateValues')] + public function benchFromStringOptimized(array $params): void + { + $this->fromStringOptimized($params['value'], $params['custom_formats']); + } + + /** + * Benchmark DateTime instance passthrough (both should be same) + */ + #[Bench\Revs(5000)] + #[Bench\Iterations(10)] + public function benchDateTimePassthrough(): void + { + $dt = new DateTime(); + $this->fromStringOriginal($dt, []); + } + + /** + * @return array}> + */ + public function provideDateValues(): array + { + return [ + 'Y-m-d H:i:s (no custom)' => [ + 'value' => '2024-06-15 14:30:00', + 'custom_formats' => [], + ], + 'Y-m-d (no custom)' => [ + 'value' => '2024-06-15', + 'custom_formats' => [], + ], + 'Czech format (no custom)' => [ + 'value' => '15. 6. 2024', + 'custom_formats' => [], + ], + 'Y-m-d H:i:s (with custom)' => [ + 'value' => '2024-06-15 14:30:00', + 'custom_formats' => ['d/m/Y', 'm-d-Y'], + ], + 'last format U (no custom)' => [ + 'value' => '1718454600', + 'custom_formats' => [], + ], + ]; + } + + /** + * Original implementation: array_merge on every call + */ + private function fromStringOriginal(mixed $value, array $formats = []): DateTime + { + $formats = array_merge($formats, [ + 'Y-m-d H:i:s.u', + 'Y-m-d H:i:s', + 'Y-m-d', + 'j. n. Y G:i:s', + 'j. n. Y G:i', + 'j. n. Y', + 'U', + ]); + + if ($value instanceof DateTime) { + return $value; + } + + foreach ($formats as $format) { + $date = DateTime::createFromFormat($format, (string) $value); + + if ($date === false) { + continue; + } + + return $date; + } + + return new DateTime(); + } + + /** + * Optimized implementation: constant default formats + */ + private function fromStringOptimized(mixed $value, array $formats = []): DateTime + { + $allFormats = $formats !== [] ? array_merge($formats, self::DEFAULT_FORMATS) : self::DEFAULT_FORMATS; + + if ($value instanceof DateTime) { + return $value; + } + + foreach ($allFormats as $format) { + $date = DateTime::createFromFormat($format, (string) $value); + + if ($date === false) { + continue; + } + + return $date; + } + + return new DateTime(); + } + +} diff --git a/benchmarks/GroupActionCollectionBench.php b/benchmarks/GroupActionCollectionBench.php new file mode 100644 index 000000000..8fd53f2fc --- /dev/null +++ b/benchmarks/GroupActionCollectionBench.php @@ -0,0 +1,162 @@ + 0 ? count($groupActions) + 1 : 1; + $groupActions[$id] = new GroupTextAction('Action ' . $i); + } + } + + /** + * Optimized ID generation using auto-increment counter + * + * @param array{action_count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideActionCounts')] + public function benchIdGenerationWithCounter(array $params): void + { + $groupActions = []; + $nextId = 1; + + for ($i = 0; $i < $params['action_count']; $i++) { + $id = $nextId++; + $groupActions[$id] = new GroupTextAction('Action ' . $i); + } + } + + /** + * Simulate the original 3-loop pattern for processing group actions + * + * @param array{action_count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideActionCounts')] + public function benchThreeLoopProcessing(array $params): void + { + $actions = $this->createMixedActions($params['action_count']); + $buttonResults = []; + $mainOptions = []; + $subActionResults = []; + + // Loop 1: button actions + foreach ($actions as $id => $action) { + if ($action instanceof GroupButtonAction) { + $buttonResults[$id] = $action->getTitle(); + } + } + + // Loop 2: main options + foreach ($actions as $id => $action) { + if (! $action instanceof GroupButtonAction) { + $mainOptions[$id] = $action->getTitle(); + } + } + + // Loop 3: sub-action controls + foreach ($actions as $id => $action) { + if ($action instanceof GroupSelectAction) { + $subActionResults[$id] = $action->getOptions(); + } elseif ($action instanceof GroupTextAction) { + $subActionResults[$id] = 'text'; + } elseif ($action instanceof GroupTextareaAction) { + $subActionResults[$id] = 'textarea'; + } + } + } + + /** + * Optimized single-loop pattern + * + * @param array{action_count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideActionCounts')] + public function benchSingleLoopProcessing(array $params): void + { + $actions = $this->createMixedActions($params['action_count']); + $buttonResults = []; + $mainOptions = []; + $subActionResults = []; + + foreach ($actions as $id => $action) { + if ($action instanceof GroupButtonAction) { + $buttonResults[$id] = $action->getTitle(); + + continue; + } + + $mainOptions[$id] = $action->getTitle(); + + if ($action instanceof GroupSelectAction) { + $subActionResults[$id] = $action->getOptions(); + } elseif ($action instanceof GroupTextAction) { + $subActionResults[$id] = 'text'; + } elseif ($action instanceof GroupTextareaAction) { + $subActionResults[$id] = 'textarea'; + } + } + } + + /** + * @return array + */ + public function provideActionCounts(): array + { + return [ + '5 actions' => ['action_count' => 5], + '20 actions' => ['action_count' => 20], + '50 actions' => ['action_count' => 50], + ]; + } + + /** + * @return array + */ + private function createMixedActions(int $count): array + { + $actions = []; + + for ($i = 1; $i <= $count; $i++) { + $actions[$i] = match ($i % 4) { + 0 => new GroupButtonAction('Button ' . $i), + 1 => new GroupSelectAction('Select ' . $i, ['a' => 'A', 'b' => 'B']), + 2 => new GroupTextAction('Text ' . $i), + 3 => new GroupTextareaAction('Textarea ' . $i), + }; + } + + return $actions; + } + +} diff --git a/benchmarks/RowValueAccessBench.php b/benchmarks/RowValueAccessBench.php new file mode 100644 index 000000000..7f34be5a6 --- /dev/null +++ b/benchmarks/RowValueAccessBench.php @@ -0,0 +1,161 @@ +arrayItem = [ + 'id' => 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + 'city' => 'Prague', + 'country' => 'CZ', + 'phone' => '+420123456789', + 'status' => 'active', + ]; + + $this->objectItem = (object) $this->arrayItem; + + // Cached accessor for array items (optimized pattern) + $item = $this->arrayItem; + $this->cachedArrayAccessor = static function (string $key) use ($item): mixed { + return $item[$key]; + }; + + // Cached accessor for object items (optimized pattern) + $obj = $this->objectItem; + $this->cachedObjectAccessor = static function (string $key) use ($obj): mixed { + return $obj->{$key}; + }; + } + + /** + * Original: instanceof chain for array items (checks 5 types before reaching array branch) + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchInstanceofChainArray(array $params): void + { + $item = $this->arrayItem; + + for ($i = 0; $i < $params['columns']; $i++) { + $key = self::COLUMNS[$i % 8]; + // Simulates the original instanceof chain for array items + // In real code: Entity, NextrasEntity, DibiRow, ActiveRow, NetteRow are checked first + if (false) { // Entity + } elseif (false) { // NextrasEntity + } elseif (false) { // DibiRow + } elseif (false) { // ActiveRow + } elseif (false) { // NetteRow + } elseif (is_array($item)) { + $value = $item[$key]; + } + } + } + + /** + * Optimized: cached closure dispatch for array items + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchCachedClosureArray(array $params): void + { + $accessor = $this->cachedArrayAccessor; + + for ($i = 0; $i < $params['columns']; $i++) { + $key = self::COLUMNS[$i % 8]; + $value = $accessor($key); + } + } + + /** + * Original: instanceof chain for generic object items (falls through to default) + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchInstanceofChainObject(array $params): void + { + $item = $this->objectItem; + + for ($i = 0; $i < $params['columns']; $i++) { + $key = self::COLUMNS[$i % 8]; + // Simulates falling through all instanceof checks to default (Doctrine path) + if (false) { // Entity + } elseif (false) { // NextrasEntity + } elseif (false) { // DibiRow + } elseif (false) { // ActiveRow + } elseif (false) { // NetteRow + } elseif (is_array($item)) { // not array + } else { + $value = $item->{$key}; + } + } + } + + /** + * Optimized: cached closure dispatch for generic object items + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchCachedClosureObject(array $params): void + { + $accessor = $this->cachedObjectAccessor; + + for ($i = 0; $i < $params['columns']; $i++) { + $key = self::COLUMNS[$i % 8]; + $value = $accessor($key); + } + } + + /** + * @return array + */ + public function provideColumnCounts(): array + { + return [ + '8 columns' => ['columns' => 8], + '20 columns' => ['columns' => 20], + '50 columns' => ['columns' => 50], + ]; + } + +} diff --git a/composer.json b/composer.json index 130c25202..f77e8dfd5 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "nette/tester": "^2.3.4", "nextras/dbal": "^4.0 || ^5.0", "nextras/orm": "^4.0 || ^5.0", + "phpbench/phpbench": "^1.2", "phpstan/phpstan": "^2.1", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-mockery": "^2.0", diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 000000000..8989619c0 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,9 @@ +{ + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.file_pattern": "*Bench.php", + "runner.php_config": { + "xdebug.mode": "off" + } +} diff --git a/src/DataSource/ArrayDataSource.php b/src/DataSource/ArrayDataSource.php index 30885f9ea..8df79924b 100644 --- a/src/DataSource/ArrayDataSource.php +++ b/src/DataSource/ArrayDataSource.php @@ -128,15 +128,7 @@ public function sort(Sorting $sorting): IDataSource krsort($data, SORT_LOCALE_STRING); } - $dataSource = []; - - foreach ($data as $i) { - foreach ($i as $item) { - $dataSource[] = $item; - } - } - - $this->setData($dataSource); + $this->setData($data !== [] ? array_merge(...array_values($data)) : []); } return $this; @@ -235,26 +227,28 @@ protected function applyFilterDateRange(mixed $row, FilterDateRange $filter): bo $values = $condition[$filter->getColumn()]; $row_value = $row[$filter->getColumn()]; - if ($values['from'] !== null && $values['from'] !== '') { + $hasFrom = $values['from'] !== null && $values['from'] !== ''; + $hasTo = $values['to'] !== null && $values['to'] !== ''; + + if (!$hasFrom && !$hasTo) { + return true; + } + + // Convert row value to DateTime once for both from/to checks + if (!($row_value instanceof DateTime)) { try { - $date_from = DateTimeHelper::tryConvertToDate($values['from'], [$format]); - $date_from->setTime(0, 0, 0); + $row_value = DateTimeHelper::tryConvertToDate($row_value); } catch (DatagridDateTimeHelperException) { return false; } + } - if (!($row_value instanceof DateTime)) { - /** - * Try to convert string to DateTime object - */ - try { - $row_value = DateTimeHelper::tryConvertToDate($row_value); - } catch (DatagridDateTimeHelperException) { - /** - * Otherwise just return raw string - */ - return false; - } + if ($hasFrom) { + try { + $date_from = DateTimeHelper::tryConvertToDate($values['from'], [$format]); + $date_from->setTime(0, 0, 0); + } catch (DatagridDateTimeHelperException) { + return false; } if ($row_value->getTimestamp() < $date_from->getTimestamp()) { @@ -262,7 +256,7 @@ protected function applyFilterDateRange(mixed $row, FilterDateRange $filter): bo } } - if ($values['to'] !== null && $values['to'] !== '') { + if ($hasTo) { try { $date_to = DateTimeHelper::tryConvertToDate($values['to'], [$format]); $date_to->setTime(23, 59, 59); @@ -270,20 +264,6 @@ protected function applyFilterDateRange(mixed $row, FilterDateRange $filter): bo return false; } - if (!($row_value instanceof DateTime)) { - /** - * Try to convert string to DateTime object - */ - try { - $row_value = DateTimeHelper::tryConvertToDate($row_value); - } catch (DatagridDateTimeHelperException) { - /** - * Otherwise just return raw string - */ - return false; - } - } - if ($row_value->getTimestamp() > $date_to->getTimestamp()) { return false; } diff --git a/src/Datagrid.php b/src/Datagrid.php index 3bec733e8..37258ff96 100644 --- a/src/Datagrid.php +++ b/src/Datagrid.php @@ -2743,13 +2743,10 @@ public function getColumns(): array $this->getParentComponent(); if (! (bool) $this->getStorageData('_grid_hidden_columns_manipulated', false)) { - $columns_to_hide = []; - - foreach ($this->columns as $key => $column) { - if ($column->getDefaultHide()) { - $columns_to_hide[] = $key; - } - } + $columns_to_hide = array_keys(array_filter( + $this->columns, + static fn ($column) => $column->getDefaultHide() + )); if ($columns_to_hide !== []) { $this->saveStorageData('_grid_hidden_columns', $columns_to_hide); diff --git a/src/GroupAction/GroupActionCollection.php b/src/GroupAction/GroupActionCollection.php index e94c69d57..00d09c965 100644 --- a/src/GroupAction/GroupActionCollection.php +++ b/src/GroupAction/GroupActionCollection.php @@ -19,6 +19,8 @@ class GroupActionCollection /** @var array */ protected array $groupActions = []; + private int $nextId = 1; + public function __construct(protected Datagrid $datagrid) { } @@ -36,42 +38,32 @@ public function addToFormContainer(Container $container): void } /** - * First foreach for adding button actions + * Single pass: add button actions, build main options, and create sub-action controls */ foreach ($this->groupActions as $id => $action) { if ($action instanceof GroupButtonAction) { $control = $container->addSubmit((string) $id, $action->getTitle()); - /** - * User may set a class to the form control - */ $control->setHtmlAttribute('class', $action->getClass()); - /** - * User may set additional attribtues to the form control - */ foreach ($action->getAttributes() as $name => $value) { $control->setHtmlAttribute($name, $value); } - } - } - /** - * Second foreach for filling "main" select - */ - foreach ($this->groupActions as $id => $action) { - if (! $action instanceof GroupButtonAction) { - $main_options[$id] = $action->getTitle(); + continue; } + + $main_options[$id] = $action->getTitle(); } $groupActionSelect = $container->addSelect('group_action', '', $main_options) ->setPrompt('contributte_datagrid.choose'); - /** - * Third for creating select for each "sub"-action - */ foreach ($this->groupActions as $id => $action) { + if ($action instanceof GroupButtonAction) { + continue; + } + $control = null; if ($action instanceof GroupSelectAction) { @@ -104,14 +96,8 @@ public function addToFormContainer(Container $container): void } if (isset($control)) { - /** - * User may set a class to the form control - */ $control->setHtmlAttribute('class', $action->getClass()); - /** - * User may set additional attribtues to the form control - */ foreach ($action->getAttributes() as $name => $value) { $control->setHtmlAttribute($name, $value); } @@ -205,7 +191,7 @@ public function submitted(NetteForm $form): void */ public function addGroupButtonAction(string $title, ?string $class = null): GroupButtonAction { - $id = count($this->groupActions) > 0 ? count($this->groupActions) + 1 : 1; + $id = $this->nextId++; return $this->groupActions[$id] = new GroupButtonAction($title, $class); } @@ -215,7 +201,7 @@ public function addGroupButtonAction(string $title, ?string $class = null): Grou */ public function addGroupSelectAction(string $title, array $options): GroupAction { - $id = count($this->groupActions) > 0 ? count($this->groupActions) + 1 : 1; + $id = $this->nextId++; return $this->groupActions[$id] = new GroupSelectAction($title, $options); } @@ -225,7 +211,7 @@ public function addGroupSelectAction(string $title, array $options): GroupAction */ public function addGroupMultiSelectAction(string $title, array $options): GroupAction { - $id = count($this->groupActions) > 0 ? count($this->groupActions) + 1 : 1; + $id = $this->nextId++; return $this->groupActions[$id] = new GroupMultiSelectAction($title, $options); } @@ -235,7 +221,7 @@ public function addGroupMultiSelectAction(string $title, array $options): GroupA */ public function addGroupTextAction(string $title): GroupAction { - $id = count($this->groupActions) > 0 ? count($this->groupActions) + 1 : 1; + $id = $this->nextId++; return $this->groupActions[$id] = new GroupTextAction($title); } @@ -245,7 +231,7 @@ public function addGroupTextAction(string $title): GroupAction */ public function addGroupTextareaAction(string $title): GroupAction { - $id = count($this->groupActions) > 0 ? count($this->groupActions) + 1 : 1; + $id = $this->nextId++; return $this->groupActions[$id] = new GroupTextareaAction($title); } diff --git a/src/Row.php b/src/Row.php index 8d736b01d..acdac810d 100644 --- a/src/Row.php +++ b/src/Row.php @@ -20,9 +20,13 @@ class Row protected Html $control; + /** @var \Closure(mixed): mixed */ + private \Closure $valueAccessor; + public function __construct(protected Datagrid $datagrid, protected mixed $item, protected string $primaryKey) { $this->control = Html::el('tr'); + $this->valueAccessor = $this->createValueAccessor(); $this->id = $this->getValue($primaryKey); if ($datagrid->getColumnsSummary() instanceof ColumnsSummary) { @@ -41,41 +45,7 @@ public function getId(): mixed public function getValue(mixed $key): mixed { - if ($this->item instanceof Entity) { - return $this->getLeanMapperEntityProperty($this->item, $key); - } - - if ($this->item instanceof NextrasEntity) { - return $this->getNextrasEntityProperty($this->item, $key); - } - - if ($this->item instanceof DibiRow) { - return $this->item[$this->formatDibiRowKey($key)]; - } - - if ($this->item instanceof ActiveRow) { - return $this->getActiveRowProperty($this->item, $key); - } - - if ($this->item instanceof NetteRow) { - return $this->item->{$key}; - } - - if (is_array($this->item)) { - $arrayValue = $this->item[$key]; - - if (is_object($arrayValue) && method_exists($arrayValue, '__toString')) { - return (string) $arrayValue; - } - - if (interface_exists(\BackedEnum::class) && $arrayValue instanceof \BackedEnum) { - return $arrayValue->value; - } - - return $arrayValue; - } - - return $this->getDoctrineEntityProperty($this->item, $key); + return ($this->valueAccessor)($key); } public function getControl(): Html @@ -288,6 +258,38 @@ public function applyColumnCallback(string $key, Column $column): Column return $column; } + /** + * Create a type-dispatching closure based on the item type. + * This avoids repeated instanceof checks on every getValue() call. + */ + private function createValueAccessor(): \Closure + { + return match (true) { + $this->item instanceof Entity => fn ($key) => $this->getLeanMapperEntityProperty($this->item, $key), + $this->item instanceof NextrasEntity => fn ($key) => $this->getNextrasEntityProperty($this->item, $key), + $this->item instanceof DibiRow => fn ($key) => $this->item[$this->formatDibiRowKey($key)], + $this->item instanceof ActiveRow => fn ($key) => $this->getActiveRowProperty($this->item, $key), + $this->item instanceof NetteRow => fn ($key) => $this->item->{$key}, + is_array($this->item) => fn ($key) => $this->getArrayValue($key), + default => fn ($key) => $this->getDoctrineEntityProperty($this->item, $key), + }; + } + + private function getArrayValue(mixed $key): mixed + { + $arrayValue = $this->item[$key]; + + if (is_object($arrayValue) && method_exists($arrayValue, '__toString')) { + return (string) $arrayValue; + } + + if (interface_exists(\BackedEnum::class) && $arrayValue instanceof \BackedEnum) { + return $arrayValue->value; + } + + return $arrayValue; + } + /** * Key may contain ".", get rid of it (+ the table alias) */ diff --git a/src/Utils/ArraysHelper.php b/src/Utils/ArraysHelper.php index aa5bb85e9..906629259 100644 --- a/src/Utils/ArraysHelper.php +++ b/src/Utils/ArraysHelper.php @@ -15,14 +15,8 @@ public static function testEmpty(iterable $array): bool if (!self::testEmpty($value)) { return false; } - } else { - if ($value) { - return false; - } - - if (in_array($value, [0, '0', false], true)) { - return false; - } + } elseif ($value !== null && $value !== '') { + return false; } } diff --git a/src/Utils/DateTimeHelper.php b/src/Utils/DateTimeHelper.php index f36875f49..783dfb404 100644 --- a/src/Utils/DateTimeHelper.php +++ b/src/Utils/DateTimeHelper.php @@ -9,6 +9,16 @@ final class DateTimeHelper { + private const DEFAULT_FORMATS = [ + 'Y-m-d H:i:s.u', + 'Y-m-d H:i:s', + 'Y-m-d', + 'j. n. Y G:i:s', + 'j. n. Y G:i', + 'j. n. Y', + 'U', + ]; + /** * Try to convert string into \DateTime object * @@ -39,15 +49,7 @@ public static function tryConvertToDate(mixed $value, array $formats = []): Date */ public static function fromString(mixed $value, array $formats = []): DateTime { - $formats = array_merge($formats, [ - 'Y-m-d H:i:s.u', - 'Y-m-d H:i:s', - 'Y-m-d', - 'j. n. Y G:i:s', - 'j. n. Y G:i', - 'j. n. Y', - 'U', - ]); + $formats = $formats !== [] ? array_merge($formats, self::DEFAULT_FORMATS) : self::DEFAULT_FORMATS; if ($value instanceof DateTime) { return $value;