From 5072a17283e18ff06cf7b125744d6bbdeeb05e35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 11:44:44 +0000 Subject: [PATCH 1/2] 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 d7a6a628b..745d81a1c 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()]; $rowValue = $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 (!($rowValue instanceof DateTime)) { try { - $dateFrom = DateTimeHelper::tryConvertToDate($values['from'], [$format]); - $dateFrom->setTime(0, 0, 0); + $rowValue = DateTimeHelper::tryConvertToDate($rowValue); } catch (DatagridDateTimeHelperException) { return false; } + } - if (!($rowValue instanceof DateTime)) { - /** - * Try to convert string to DateTime object - */ - try { - $rowValue = DateTimeHelper::tryConvertToDate($rowValue); - } catch (DatagridDateTimeHelperException) { - /** - * Otherwise just return raw string - */ - return false; - } + if ($hasFrom) { + try { + $dateFrom = DateTimeHelper::tryConvertToDate($values['from'], [$format]); + $dateFrom->setTime(0, 0, 0); + } catch (DatagridDateTimeHelperException) { + return false; } if ($rowValue->getTimestamp() < $dateFrom->getTimestamp()) { @@ -262,7 +256,7 @@ protected function applyFilterDateRange(mixed $row, FilterDateRange $filter): bo } } - if ($values['to'] !== null && $values['to'] !== '') { + if ($hasTo) { try { $dateTo = DateTimeHelper::tryConvertToDate($values['to'], [$format]); $dateTo->setTime(23, 59, 59); @@ -270,20 +264,6 @@ protected function applyFilterDateRange(mixed $row, FilterDateRange $filter): bo return false; } - if (!($rowValue instanceof DateTime)) { - /** - * Try to convert string to DateTime object - */ - try { - $rowValue = DateTimeHelper::tryConvertToDate($rowValue); - } catch (DatagridDateTimeHelperException) { - /** - * Otherwise just return raw string - */ - return false; - } - } - if ($rowValue->getTimestamp() > $dateTo->getTimestamp()) { return false; } diff --git a/src/Datagrid.php b/src/Datagrid.php index b012d9136..67b1b140e 100644 --- a/src/Datagrid.php +++ b/src/Datagrid.php @@ -2742,13 +2742,10 @@ public function getColumns(): array $this->getParentComponent(); if (! (bool) $this->getStorageData('_grid_hidden_columns_manipulated', false)) { - $columnsToHide = []; - - foreach ($this->columns as $key => $column) { - if ($column->getDefaultHide()) { - $columnsToHide[] = $key; - } - } + $columnsToHide = array_keys(array_filter( + $this->columns, + static fn ($column) => $column->getDefaultHide() + )); if ($columnsToHide !== []) { $this->saveStorageData('_grid_hidden_columns', $columnsToHide); diff --git a/src/GroupAction/GroupActionCollection.php b/src/GroupAction/GroupActionCollection.php index f83141d58..e603b5122 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) { - $mainOptions[$id] = $action->getTitle(); + continue; } + + $mainOptions[$id] = $action->getTitle(); } $groupActionSelect = $container->addSelect('group_action', '', $mainOptions) ->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); } @@ -208,7 +194,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); } @@ -218,7 +204,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); } @@ -228,7 +214,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); } @@ -238,7 +224,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); } @@ -248,7 +234,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; From c5c98d189f0a1fce3706c14369a0dfd3b8d5ff58 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 20:49:35 +0000 Subject: [PATCH 2/2] Add 5 additional PHPBench benchmark classes New benchmarks covering broader performance areas: - DataModelBench: filter, sort, paginate, full pipeline on ArrayDataSource - ColumnRenderBench: element cache, HTML creation/cloning, column types, callback/replacement/plain rendering paths - PropertyAccessBench: singleton accessor, Symfony PropertyAccessor depths, explode overhead, array vs object vs accessor comparison - FilterApplicationBench: text/select/range/multiselect filters, word splitting, conjunction search, multiple filters in sequence - CsvExportBench: strip_tags, value extraction, summary aggregation, fputcsv serialization, full CSV pipeline Total: 10 benchmark classes, ~130 variants across all data sizes. https://claude.ai/code/session_014wj5NNHPqbkW4LnnyXX2sB --- benchmarks/ColumnRenderBench.php | 527 ++++++++++++++++++++++++++ benchmarks/CsvExportBench.php | 299 +++++++++++++++ benchmarks/DataModelBench.php | 217 +++++++++++ benchmarks/FilterApplicationBench.php | 296 +++++++++++++++ benchmarks/PropertyAccessBench.php | 331 ++++++++++++++++ 5 files changed, 1670 insertions(+) create mode 100644 benchmarks/ColumnRenderBench.php create mode 100644 benchmarks/CsvExportBench.php create mode 100644 benchmarks/DataModelBench.php create mode 100644 benchmarks/FilterApplicationBench.php create mode 100644 benchmarks/PropertyAccessBench.php diff --git a/benchmarks/ColumnRenderBench.php b/benchmarks/ColumnRenderBench.php new file mode 100644 index 000000000..a39c19f60 --- /dev/null +++ b/benchmarks/ColumnRenderBench.php @@ -0,0 +1,527 @@ +textColumns[$i]; + + // First call populates the cache + $column->getElementPrototype('td'); + + // Subsequent calls should hit the cache + $column->getElementPrototype('td'); + $column->getElementPrototype('td'); + } + } + + /** + * getElementPrototype() with alternating td/th tags -- both cache slots. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpTextColumns')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchElementCacheBothTags(array $params): void + { + for ($i = 0; $i < $params['columns']; $i++) { + $column = $this->textColumns[$i]; + $column->getElementPrototype('td'); + $column->getElementPrototype('th'); + } + } + + // --------------------------------------------------------------- + // 2. Raw Html::el() creation and cloning + // --------------------------------------------------------------- + + /** + * Baseline: creating Html::el('td') fresh every time (no cache). + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchHtmlElCreationFresh(array $params): void + { + for ($i = 0; $i < $params['columns']; $i++) { + $td = Html::el('td'); + $th = Html::el('th'); + } + } + + /** + * Cloning a cached Html prototype (the pattern used inside getElementForRender). + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpHtmlPrototypes')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchHtmlElCloneFromCache(array $params): void + { + $tdProto = $this->htmlPrototypes['td']; + $thProto = $this->htmlPrototypes['th']; + + for ($i = 0; $i < $params['columns']; $i++) { + $td = clone $tdProto; + $th = clone $thProto; + } + } + + /** + * Clone + appendAttribute (simulates the full getElementForRender flow without Datagrid dependency). + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpHtmlPrototypes')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchHtmlElCloneWithAttributes(array $params): void + { + $tdProto = $this->htmlPrototypes['td']; + + for ($i = 0; $i < $params['columns']; $i++) { + $el = clone $tdProto; + $el->appendAttribute('class', sprintf('text-%s', 'start')); + $el->appendAttribute('class', sprintf('col-%s', 'column_' . $i)); + } + } + + /** + * Fresh Html::el() + appendAttribute (no cache, no clone). + * + * @param array{columns: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchHtmlElFreshWithAttributes(array $params): void + { + for ($i = 0; $i < $params['columns']; $i++) { + $el = Html::el('td'); + $el->appendAttribute('class', sprintf('text-%s', 'start')); + $el->appendAttribute('class', sprintf('col-%s', 'column_' . $i)); + } + } + + // --------------------------------------------------------------- + // 3. Column creation overhead + // --------------------------------------------------------------- + + /** + * ColumnText creation overhead. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchColumnTextCreation(array $params): void + { + $grid = $this->createGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + new ColumnText($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i); + } + + Mockery::close(); + } + + /** + * ColumnNumber creation overhead. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchColumnNumberCreation(array $params): void + { + $grid = $this->createGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + new ColumnNumber($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i); + } + + Mockery::close(); + } + + /** + * ColumnDateTime creation overhead. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchColumnDateTimeCreation(array $params): void + { + $grid = $this->createGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + new ColumnDateTime($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i); + } + + Mockery::close(); + } + + /** + * Mixed column type creation (simulates a real datagrid with diverse column types). + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchMixedColumnCreation(array $params): void + { + $grid = $this->createGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + match ($i % 3) { + 0 => new ColumnText($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i), + 1 => new ColumnNumber($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i), + 2 => new ColumnDateTime($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i), + }; + } + + Mockery::close(); + } + + // --------------------------------------------------------------- + // 4. Cell attributes + // --------------------------------------------------------------- + + /** + * addCellAttributes applies attributes to both td and th prototypes. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpTextColumns')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchAddCellAttributes(array $params): void + { + for ($i = 0; $i < $params['columns']; $i++) { + $this->textColumns[$i]->addCellAttributes([ + 'class' => 'datagrid-fit-content', + 'data-column' => 'col_' . $i, + ]); + } + } + + // --------------------------------------------------------------- + // 5. Callback renderer vs replacement vs plain value rendering + // --------------------------------------------------------------- + + /** + * Column::render() with a custom callback renderer set via setRenderer(). + * Exercises the useRenderer() -> call_user_func_array() path. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpRenderingColumns')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchRenderWithCallback(array $params): void + { + $count = min($params['columns'], count($this->rendererColumns)); + + for ($i = 0; $i < $count; $i++) { + $this->rendererColumns[$i]->render($this->rows[$i]); + } + } + + /** + * Column::render() with replacements set via setReplacement(). + * Exercises the applyReplacements() path. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpRenderingColumns')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchRenderWithReplacement(array $params): void + { + $count = min($params['columns'], count($this->replacementColumns)); + + for ($i = 0; $i < $count; $i++) { + $this->replacementColumns[$i]->render($this->rows[$i]); + } + } + + /** + * Column::render() with no renderer and no replacements -- plain getColumnValue(). + * Exercises the simplest path through render(). + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpRenderingColumns')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchRenderPlainValue(array $params): void + { + $count = min($params['columns'], count($this->plainColumns)); + + for ($i = 0; $i < $count; $i++) { + $this->plainColumns[$i]->render($this->rows[$i]); + } + } + + /** + * Column::render() with a conditional renderer (condition callback returns true). + * Tests the overhead of the condition check before the renderer callback. + * + * @param array{columns: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpConditionalRendererColumns')] + #[Bench\ParamProviders('provideColumnCounts')] + public function benchRenderWithConditionalCallback(array $params): void + { + $count = min($params['columns'], count($this->rendererColumns)); + + for ($i = 0; $i < $count; $i++) { + $this->rendererColumns[$i]->render($this->rows[$i]); + } + } + + // --------------------------------------------------------------- + // Param providers + // --------------------------------------------------------------- + + /** + * @return array + */ + public function provideColumnCounts(): array + { + return [ + '8 columns' => ['columns' => 8], + '20 columns' => ['columns' => 20], + '50 columns' => ['columns' => 50], + ]; + } + + // --------------------------------------------------------------- + // Setup methods + // --------------------------------------------------------------- + + /** + * @param array{columns: int} $params + */ + public function setUpTextColumns(array $params): void + { + $this->textColumns = []; + $grid = $this->createGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + $this->textColumns[$i] = new ColumnText($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i); + } + } + + /** + * @param array{columns: int} $params + */ + public function setUpNumberColumns(array $params): void + { + $this->numberColumns = []; + $grid = $this->createGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + $this->numberColumns[$i] = new ColumnNumber($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i); + } + } + + /** + * @param array{columns: int} $params + */ + public function setUpDateTimeColumns(array $params): void + { + $this->dateTimeColumns = []; + $grid = $this->createGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + $this->dateTimeColumns[$i] = new ColumnDateTime($grid, 'col_' . $i, 'column_' . $i, 'Column ' . $i); + } + } + + public function setUpHtmlPrototypes(): void + { + $this->htmlPrototypes = [ + 'td' => Html::el('td'), + 'th' => Html::el('th'), + ]; + } + + /** + * Set up three groups of columns for render-path benchmarks: + * - rendererColumns: have a custom callback renderer set + * - replacementColumns: have a replacement map set + * - plainColumns: no renderer, no replacements (raw value path) + * + * Also creates matching Row objects backed by simple arrays. + * + * @param array{columns: int} $params + */ + public function setUpRenderingColumns(array $params): void + { + $this->rendererColumns = []; + $this->replacementColumns = []; + $this->plainColumns = []; + $this->rows = []; + + $grid = $this->createRowCompatibleGridMock(); + $statuses = ['active', 'inactive', 'pending']; + + for ($i = 0; $i < $params['columns']; $i++) { + $item = [ + 'id' => $i + 1, + 'name' => 'Item ' . $i, + 'status' => $statuses[$i % 3], + ]; + + $this->rows[$i] = new Row($grid, $item, 'id'); + + // Columns with callback renderer + $col = new ColumnText($grid, 'name_r_' . $i, 'name', 'Name'); + $col->setRenderer(fn (array $item): string => strtoupper((string) $item['name'])); + $this->rendererColumns[$i] = $col; + + // Columns with replacement map + $col = new ColumnText($grid, 'status_' . $i, 'status', 'Status'); + $col->setReplacement([ + 'active' => 'Active', + 'inactive' => 'Inactive', + 'pending' => 'Pending', + ]); + $this->replacementColumns[$i] = $col; + + // Plain columns (no renderer, no replacements) + $this->plainColumns[$i] = new ColumnText($grid, 'name_p_' . $i, 'name', 'Name'); + } + } + + /** + * Set up columns with conditional renderers (condition callback + renderer callback). + * + * @param array{columns: int} $params + */ + public function setUpConditionalRendererColumns(array $params): void + { + $this->rendererColumns = []; + $this->rows = []; + + $grid = $this->createRowCompatibleGridMock(); + + for ($i = 0; $i < $params['columns']; $i++) { + $item = [ + 'id' => $i + 1, + 'name' => 'Item ' . $i, + ]; + + $this->rows[$i] = new Row($grid, $item, 'id'); + + $col = new ColumnText($grid, 'name_cr_' . $i, 'name', 'Name'); + $col->setRendererOnCondition( + fn (array $item): string => strtoupper((string) $item['name']), + fn (array $item): bool => true, + ); + $this->rendererColumns[$i] = $col; + } + } + + // --------------------------------------------------------------- + // Mock helpers + // --------------------------------------------------------------- + + private function createGridMock(): Datagrid + { + /** @var Datagrid $grid */ + $grid = Mockery::mock(Datagrid::class); + + return $grid; + } + + /** + * Create a Datagrid mock that supports Row construction. + * Row::__construct() calls $datagrid->getColumnsSummary() and $datagrid->getColumnCallback(). + */ + private function createRowCompatibleGridMock(): Datagrid + { + /** @var Datagrid&\Mockery\MockInterface $grid */ + $grid = Mockery::mock(Datagrid::class); + $grid->shouldReceive('getColumnsSummary')->andReturn(null); + + return $grid; + } + +} diff --git a/benchmarks/CsvExportBench.php b/benchmarks/CsvExportBench.php new file mode 100644 index 000000000..f818899b9 --- /dev/null +++ b/benchmarks/CsvExportBench.php @@ -0,0 +1,299 @@ +> */ + private array $rows = []; + + /** @var array */ + private array $htmlStrings = []; + + /** @var array */ + private array $summaryAccumulator = []; + + /** + * Benchmark strip_tags on HTML strings — the main per-cell cost in CsvDataModel::getRow() + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpHtmlStrings')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchStripTagsPerCell(array $params): void + { + $columns = ['name', 'email', 'status', 'amount']; + + foreach ($this->rows as $row) { + $csvRow = []; + + foreach ($columns as $col) { + $csvRow[] = strip_tags((string) $row[$col]); + } + } + } + + /** + * Benchmark strip_tags on pre-rendered HTML with nested tags — worst-case scenario + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpHtmlStrings')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchStripTagsComplexHtml(array $params): void + { + foreach ($this->htmlStrings as $html) { + strip_tags($html); + } + } + + /** + * Benchmark column value extraction — iterating rows and building output arrays + * as done in CsvDataModel::getSimpleData() + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchColumnValueExtraction(array $params): void + { + $columns = ['name', 'email', 'status', 'amount']; + $result = []; + + // Header row + $result[] = $columns; + + // Data rows — mirrors CsvDataModel::getSimpleData() + foreach ($this->rows as $item) { + $row = []; + + foreach ($columns as $col) { + $row[] = (string) $item[$col]; + } + + $result[] = $row; + } + } + + /** + * Benchmark ColumnsSummary::add() — summing numeric values across rows + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchColumnsSummaryAdd(array $params): void + { + $summaryColumns = ['amount', 'quantity']; + $summary = array_fill_keys($summaryColumns, 0); + + foreach ($this->rows as $row) { + foreach ($summaryColumns as $key) { + $summary[$key] += $row[$key]; + } + } + } + + /** + * Benchmark ColumnsSummary::add() with callback — custom value extraction per row + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchColumnsSummaryAddWithCallback(array $params): void + { + $summaryColumns = ['amount', 'quantity']; + $summary = array_fill_keys($summaryColumns, 0); + $callback = static fn (array $item, string $column): int|float => $item[$column] * 1; + + foreach ($this->rows as $row) { + foreach ($summaryColumns as $key) { + $summary[$key] += $callback($row, $key); + } + } + } + + /** + * Benchmark ColumnsSummary::render() — number_format on accumulated totals + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpSummaryAccumulator')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchColumnsSummaryRender(array $params): void + { + $formats = [ + 'amount' => [2, '.', ' '], + 'quantity' => [0, '.', ' '], + ]; + + foreach ($this->summaryAccumulator as $key => $value) { + number_format( + (float) $value, + $formats[$key][0], + $formats[$key][1], + $formats[$key][2] + ); + } + } + + /** + * Benchmark CSV line serialization via fputcsv — mirrors CsvResponse::printCsv() + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpRows')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFputcsvSerialization(array $params): void + { + $columns = ['name', 'email', 'status', 'amount']; + $delimiter = ';'; + + foreach ($this->rows as $item) { + $row = []; + + foreach ($columns as $col) { + $row[] = strip_tags((string) $item[$col]); + } + + $out = fopen('php://memory', 'wb+'); + fputcsv($out, $row, $delimiter, escape: '\\'); + rewind($out); + $line = stream_get_contents($out); + fclose($out); + } + } + + /** + * Benchmark full CSV export pipeline — extract, strip_tags, serialize, encode + * Combines all steps as they happen in a real export + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUpHtmlStrings')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFullCsvRowPipeline(array $params): void + { + $columns = ['name', 'email', 'status', 'amount']; + $delimiter = ';'; + $output = ''; + + foreach ($this->rows as $item) { + $row = []; + + foreach ($columns as $col) { + $row[] = strip_tags((string) $item[$col]); + } + + $out = fopen('php://memory', 'wb+'); + fputcsv($out, $row, $delimiter, escape: '\\'); + rewind($out); + $output .= stream_get_contents($out); + fclose($out); + } + } + + /** + * @return array + */ + public function provideRowCounts(): array + { + return [ + '100 rows' => ['row_count' => 100], + '500 rows' => ['row_count' => 500], + '2000 rows' => ['row_count' => 2000], + ]; + } + + /** + * @param array{row_count: int} $params + */ + public function setUpRows(array $params): void + { + $this->rows = []; + $statuses = ['active', 'inactive', 'pending', 'archived']; + + for ($i = 0; $i < $params['row_count']; $i++) { + $this->rows[] = [ + 'id' => $i, + 'name' => 'User ' . $i, + 'email' => 'user' . $i . '@example.com', + 'status' => $statuses[$i % count($statuses)], + 'amount' => round($i * 1.5 + 10.99, 2), + 'quantity' => $i % 50 + 1, + ]; + } + } + + /** + * @param array{row_count: int} $params + */ + public function setUpHtmlStrings(array $params): void + { + $this->setUpRows($params); + + $this->htmlStrings = []; + $templates = [ + ' Active', + 'User %d', + '$%s', + '
Pending (since 2024)
', + ]; + + for ($i = 0; $i < $params['row_count']; $i++) { + $this->htmlStrings[] = sprintf($templates[$i % count($templates)], $i, $i, number_format($i * 1.5, 2)); + } + + // Also set HTML into the row values to simulate rendered column output + foreach ($this->rows as $idx => &$row) { + $row['name'] = sprintf('%s', $idx, $row['name']); + $row['status'] = sprintf('%s', $row['status'], $row['status']); + } + + unset($row); + } + + /** + * @param array{row_count: int} $params + */ + public function setUpSummaryAccumulator(array $params): void + { + $this->summaryAccumulator = [ + 'amount' => 0, + 'quantity' => 0, + ]; + + for ($i = 0; $i < $params['row_count']; $i++) { + $this->summaryAccumulator['amount'] += round($i * 1.5 + 10.99, 2); + $this->summaryAccumulator['quantity'] += $i % 50 + 1; + } + } + +} diff --git a/benchmarks/DataModelBench.php b/benchmarks/DataModelBench.php new file mode 100644 index 000000000..7eb92dfb5 --- /dev/null +++ b/benchmarks/DataModelBench.php @@ -0,0 +1,217 @@ +grid = new Datagrid(); + $this->rows = []; + + $names = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Hank']; + $cities = ['Prague', 'Berlin', 'London', 'Paris', 'Vienna', 'Rome', 'Madrid', 'Oslo']; + + for ($i = 0; $i < $params['row_count']; $i++) { + $this->rows[] = [ + 'id' => $i + 1, + 'name' => $names[$i % count($names)], + 'city' => $cities[$i % count($cities)], + 'age' => ($i % 60) + 18, + ]; + } + } + + // ------------------------------------------------------------------ + // 1. Filtering with FilterText + // ------------------------------------------------------------------ + + /** + * Filter rows using a FilterText on the "name" column. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFilterText(array $params): void + { + $dataSource = new ArrayDataSource($this->rows); + + $filter = new FilterText($this->grid, 'name', 'Name', ['name']); + $filter->setValue('Ali'); + + $dataSource->filter([$filter]); + $dataSource->getData(); + } + + /** + * Filter rows using a FilterText that searches across two columns. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFilterTextMultiColumn(array $params): void + { + $dataSource = new ArrayDataSource($this->rows); + + $filter = new FilterText($this->grid, 'search', 'Search', ['name', 'city']); + $filter->setValue('a'); + + $dataSource->filter([$filter]); + $dataSource->getData(); + } + + // ------------------------------------------------------------------ + // 2. Sorting + // ------------------------------------------------------------------ + + /** + * Sort rows by a single column in ascending order. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchSortAsc(array $params): void + { + $dataSource = new ArrayDataSource($this->rows); + $sorting = new Sorting(['name' => 'ASC']); + + $dataSource->sort($sorting); + $dataSource->getData(); + } + + /** + * Sort rows by a single column in descending order. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchSortDesc(array $params): void + { + $dataSource = new ArrayDataSource($this->rows); + $sorting = new Sorting(['name' => 'DESC']); + + $dataSource->sort($sorting); + $dataSource->getData(); + } + + // ------------------------------------------------------------------ + // 3. Pagination (limit / offset) + // ------------------------------------------------------------------ + + /** + * Paginate from the beginning of the dataset. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchPaginateFirstPage(array $params): void + { + $dataSource = new ArrayDataSource($this->rows); + + $dataSource->limit(0, 20); + $dataSource->getData(); + } + + /** + * Paginate from the middle of the dataset. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(500)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchPaginateMiddlePage(array $params): void + { + $dataSource = new ArrayDataSource($this->rows); + $offset = (int) ($params['row_count'] / 2); + + $dataSource->limit($offset, 20); + $dataSource->getData(); + } + + // ------------------------------------------------------------------ + // Combined: filter + sort + paginate (full filterData flow) + // ------------------------------------------------------------------ + + /** + * Simulate the full DataModel::filterData() flow: + * filter -> sort -> limit -> getData. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFullFilterDataFlow(array $params): void + { + $dataSource = new ArrayDataSource($this->rows); + + // 1. Filter + $filter = new FilterText($this->grid, 'name', 'Name', ['name']); + $filter->setValue('a'); + $dataSource->filter([$filter]); + + // 2. Sort + $sorting = new Sorting(['name' => 'ASC']); + $dataSource->sort($sorting); + + // 3. Paginate + $dataSource->limit(0, 20); + + // 4. Retrieve data + $dataSource->getData(); + } + + // ------------------------------------------------------------------ + // Param providers + // ------------------------------------------------------------------ + + /** + * @return array + */ + public function provideRowCounts(): array + { + return [ + '50 rows' => ['row_count' => 50], + '200 rows' => ['row_count' => 200], + '1000 rows' => ['row_count' => 1000], + ]; + } + +} diff --git a/benchmarks/FilterApplicationBench.php b/benchmarks/FilterApplicationBench.php new file mode 100644 index 000000000..39f9d26e7 --- /dev/null +++ b/benchmarks/FilterApplicationBench.php @@ -0,0 +1,296 @@ +grid, 'name', 'Name', ['name']); + $filter->setValue('Alice Smith'); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filter]); + $ds->getCount(); + } + + // ------------------------------------------------------------------ + // FilterText: no word splitting (whole-phrase substring match) + // ------------------------------------------------------------------ + + /** + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFilterTextWithoutWordSplitting(array $params): void + { + $filter = new FilterText($this->grid, 'name', 'Name', ['name']); + $filter->setValue('Alice Smith'); + $filter->setSplitWordsSearch(false); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filter]); + $ds->getCount(); + } + + // ------------------------------------------------------------------ + // FilterText: conjunction search (all words must match) + // ------------------------------------------------------------------ + + /** + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFilterTextConjunction(array $params): void + { + $filter = new FilterText($this->grid, 'name', 'Name', ['name']); + $filter->setValue('Alice Smith'); + $filter->setConjunctionSearch(true); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filter]); + $ds->getCount(); + } + + // ------------------------------------------------------------------ + // FilterSelect + // ------------------------------------------------------------------ + + /** + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFilterSelect(array $params): void + { + $options = array_combine(self::STATUSES, array_map('ucfirst', self::STATUSES)); + $filter = new FilterSelect($this->grid, 'status', 'Status', $options, 'status'); + $filter->setValue('active'); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filter]); + $ds->getCount(); + } + + // ------------------------------------------------------------------ + // FilterRange (numeric) + // ------------------------------------------------------------------ + + /** + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFilterRangeFullRange(array $params): void + { + $filter = new FilterRange($this->grid, 'price', 'Price From', 'price', 'Price To'); + $filter->setValue(['from' => 100, 'to' => 500]); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filter]); + $ds->getCount(); + } + + /** + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchFilterRangeFromOnly(array $params): void + { + $filter = new FilterRange($this->grid, 'price', 'Price From', 'price', 'Price To'); + $filter->setValue(['from' => 400, 'to' => '']); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filter]); + $ds->getCount(); + } + + // ------------------------------------------------------------------ + // FilterMultiSelect: varying number of selected values + // ------------------------------------------------------------------ + + /** + * @param array{row_count: int, selected: string[]} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideMultiSelectParams')] + public function benchFilterMultiSelect(array $params): void + { + $options = array_combine(self::STATUSES, array_map('ucfirst', self::STATUSES)); + $filter = new FilterMultiSelect($this->grid, 'status', 'Status', $options, 'status'); + $filter->setValue($params['selected']); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filter]); + $ds->getCount(); + } + + // ------------------------------------------------------------------ + // Multiple filters applied in sequence + // ------------------------------------------------------------------ + + /** + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchMultipleFiltersSequential(array $params): void + { + $options = array_combine(self::STATUSES, array_map('ucfirst', self::STATUSES)); + + $filterText = new FilterText($this->grid, 'name', 'Name', ['name']); + $filterText->setValue('Alice'); + + $filterSelect = new FilterSelect($this->grid, 'status', 'Status', $options, 'status'); + $filterSelect->setValue('active'); + + $filterRange = new FilterRange($this->grid, 'price', 'Price From', 'price', 'Price To'); + $filterRange->setValue(['from' => 100, 'to' => 800]); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filterText, $filterSelect, $filterRange]); + $ds->getCount(); + } + + /** + * Apply all four filter types in sequence. + * + * @param array{row_count: int} $params + */ + #[Bench\Revs(100)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideRowCounts')] + public function benchAllFilterTypesCombined(array $params): void + { + $options = array_combine(self::STATUSES, array_map('ucfirst', self::STATUSES)); + + $filterText = new FilterText($this->grid, 'name', 'Name', ['name']); + $filterText->setValue('Bob'); + + $filterRange = new FilterRange($this->grid, 'price', 'Price From', 'price', 'Price To'); + $filterRange->setValue(['from' => 50, 'to' => 600]); + + $filterMulti = new FilterMultiSelect($this->grid, 'status', 'Status', $options, 'status'); + $filterMulti->setValue(['active', 'pending']); + + $ds = new ArrayDataSource($this->data); + $ds->filter([$filterText, $filterRange, $filterMulti]); + $ds->getCount(); + } + + // ------------------------------------------------------------------ + // Param providers + // ------------------------------------------------------------------ + + /** + * @return array + */ + public function provideRowCounts(): array + { + return [ + '100 rows' => ['row_count' => 100], + '500 rows' => ['row_count' => 500], + '2000 rows' => ['row_count' => 2000], + ]; + } + + /** + * @return array + */ + public function provideMultiSelectParams(): array + { + return [ + '100 rows, 1 selected' => ['row_count' => 100, 'selected' => ['active']], + '100 rows, 3 selected' => ['row_count' => 100, 'selected' => ['active', 'pending', 'archived']], + '500 rows, 1 selected' => ['row_count' => 500, 'selected' => ['active']], + '500 rows, 3 selected' => ['row_count' => 500, 'selected' => ['active', 'pending', 'archived']], + '2000 rows, 1 selected' => ['row_count' => 2000, 'selected' => ['active']], + '2000 rows, 3 selected' => ['row_count' => 2000, 'selected' => ['active', 'pending', 'archived']], + '2000 rows, 5 selected' => ['row_count' => 2000, 'selected' => ['active', 'pending', 'archived', 'inactive', 'deleted']], + ]; + } + + // ------------------------------------------------------------------ + // Setup + // ------------------------------------------------------------------ + + /** + * @param array{row_count: int} $params + */ + public function setUp(array $params): void + { + $this->grid = new Datagrid(); + $this->data = []; + + $firstNames = self::FIRST_NAMES; + $lastNames = self::LAST_NAMES; + $statuses = self::STATUSES; + + for ($i = 0; $i < $params['row_count']; $i++) { + $this->data[] = [ + 'id' => $i + 1, + 'name' => $firstNames[$i % count($firstNames)] . ' ' . $lastNames[$i % count($lastNames)], + 'status' => $statuses[$i % count($statuses)], + 'price' => round(($i * 7.3 + 10) % 1000, 2), + 'date' => '2024-' . str_pad((string) (($i % 12) + 1), 2, '0', STR_PAD_LEFT) . '-' + . str_pad((string) (($i % 28) + 1), 2, '0', STR_PAD_LEFT), + ]; + } + } + +} diff --git a/benchmarks/PropertyAccessBench.php b/benchmarks/PropertyAccessBench.php new file mode 100644 index 000000000..1b8a7e6a5 --- /dev/null +++ b/benchmarks/PropertyAccessBench.php @@ -0,0 +1,331 @@ +accessor = PropertyAccessHelper::getAccessor(); + + $this->arrayItem = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + ]; + + $this->flatObject = new stdClass(); + $this->flatObject->name = 'John Doe'; + $this->flatObject->email = 'john@example.com'; + $this->flatObject->age = 30; + + $country = new stdClass(); + $country->code = 'CZ'; + $country->label = 'Czech Republic'; + + $address = new stdClass(); + $address->city = 'Prague'; + $address->country = $country; + + $this->nestedObject = new stdClass(); + $this->nestedObject->name = 'John Doe'; + $this->nestedObject->address = $address; + } + + // ------------------------------------------------------------------------- + // 1. PropertyAccessHelper::getAccessor() — lazy singleton pattern + // ------------------------------------------------------------------------- + + /** + * First call: forces PropertyAccessor creation (resets singleton each iteration) + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchGetAccessorFirstCall(array $params): void + { + for ($i = 0; $i < $params['count']; $i++) { + // Simulate the cold path: create a new PropertyAccessor each time + $accessor = PropertyAccess::createPropertyAccessor(); + } + } + + /** + * Subsequent calls: returns cached singleton via PropertyAccessHelper + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchGetAccessorCached(array $params): void + { + for ($i = 0; $i < $params['count']; $i++) { + $accessor = PropertyAccessHelper::getAccessor(); + } + } + + // ------------------------------------------------------------------------- + // 2. Symfony PropertyAccessor: simple vs nested property paths + // ------------------------------------------------------------------------- + + /** + * PropertyAccessor with a simple (non-dotted) property path: 'name' + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchPropertyAccessorSimplePath(array $params): void + { + $accessor = $this->accessor; + $object = $this->nestedObject; + + for ($i = 0; $i < $params['count']; $i++) { + $value = $accessor->getValue($object, 'name'); + } + } + + /** + * PropertyAccessor with a two-level nested path: 'address.city' + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchPropertyAccessorNestedPath(array $params): void + { + $accessor = $this->accessor; + $object = $this->nestedObject; + + for ($i = 0; $i < $params['count']; $i++) { + $value = $accessor->getValue($object, 'address.city'); + } + } + + /** + * PropertyAccessor with a three-level nested path: 'address.country.code' + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchPropertyAccessorDeeplyNestedPath(array $params): void + { + $accessor = $this->accessor; + $object = $this->nestedObject; + + for ($i = 0; $i < $params['count']; $i++) { + $value = $accessor->getValue($object, 'address.country.code'); + } + } + + // ------------------------------------------------------------------------- + // 3. explode('.', $key) overhead — present in every entity property method + // ------------------------------------------------------------------------- + + /** + * explode() on a simple key with no dots: 'name' + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchExplodeSimpleKey(array $params): void + { + for ($i = 0; $i < $params['count']; $i++) { + $properties = explode('.', 'name'); + } + } + + /** + * explode() on a two-level dotted key: 'address.city' + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchExplodeDottedKey(array $params): void + { + for ($i = 0; $i < $params['count']; $i++) { + $properties = explode('.', 'address.city'); + } + } + + /** + * explode() on a three-level dotted key: 'address.country.code' + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchExplodeDeeplyDottedKey(array $params): void + { + for ($i = 0; $i < $params['count']; $i++) { + $properties = explode('.', 'address.country.code'); + } + } + + /** + * Full explode + array_shift loop as used in getDoctrineEntityProperty() + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchExplodeWithShiftLoop(array $params): void + { + $accessor = $this->accessor; + $object = $this->nestedObject; + + for ($i = 0; $i < $params['count']; $i++) { + $properties = explode('.', 'address.country.code'); + $value = $object; + + while ($property = array_shift($properties)) { + if (!is_object($value)) { + break; + } + + $value = $accessor->getValue($value, $property); + } + } + } + + // ------------------------------------------------------------------------- + // 4. Array key access vs object property access vs PropertyAccessor + // ------------------------------------------------------------------------- + + /** + * Direct array key access: $item[$key] + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchArrayKeyAccess(array $params): void + { + $item = $this->arrayItem; + $keys = ['name', 'email', 'age']; + + for ($i = 0; $i < $params['count']; $i++) { + $key = $keys[$i % 3]; + $value = $item[$key]; + } + } + + /** + * Direct object property access: $item->{$key} + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchObjectPropertyAccess(array $params): void + { + $item = $this->flatObject; + $keys = ['name', 'email', 'age']; + + for ($i = 0; $i < $params['count']; $i++) { + $key = $keys[$i % 3]; + $value = $item->{$key}; + } + } + + /** + * Symfony PropertyAccessor access: $accessor->getValue($item, $key) + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchPropertyAccessorAccess(array $params): void + { + $accessor = $this->accessor; + $item = $this->flatObject; + $keys = ['name', 'email', 'age']; + + for ($i = 0; $i < $params['count']; $i++) { + $key = $keys[$i % 3]; + $value = $accessor->getValue($item, $key); + } + } + + /** + * PropertyAccessHelper::getValue() static wrapper (includes singleton lookup) + * + * @param array{count: int} $params + */ + #[Bench\Revs(1000)] + #[Bench\Iterations(10)] + #[Bench\BeforeMethods('setUp')] + #[Bench\ParamProviders('provideAccessCounts')] + public function benchPropertyAccessHelperStaticAccess(array $params): void + { + $item = $this->flatObject; + $keys = ['name', 'email', 'age']; + + for ($i = 0; $i < $params['count']; $i++) { + $key = $keys[$i % 3]; + $value = PropertyAccessHelper::getValue($item, $key); + } + } + + // ------------------------------------------------------------------------- + // Param providers + // ------------------------------------------------------------------------- + + /** + * @return array + */ + public function provideAccessCounts(): array + { + return [ + '10 accesses' => ['count' => 10], + '50 accesses' => ['count' => 50], + '200 accesses' => ['count' => 200], + ]; + } + +}