diff --git a/VERSION.txt b/VERSION.txt index f4b80628c60..921beff9a72 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -16,4 +16,4 @@ // @license https://opensource.org/licenses/mit-license.php MIT License // +--------------------------------------------------------------------------------------------+ // //////////////////////////////////////////////////////////////////////////////////////////////////// -5.3.3 +5.4.0-dev diff --git a/composer.json b/composer.json index ca5583e4b73..19a8720360e 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0.2", "psr/http-server-middleware": "^1.0.2", + "psr/link": "^2.0", "psr/log": "^3.0", "psr/simple-cache": "^2.0 || ^3.0" }, @@ -44,6 +45,7 @@ "cakephp/cache": "self.version", "cakephp/collection": "self.version", "cakephp/console": "self.version", + "cakephp/container": "self.version", "cakephp/core": "self.version", "cakephp/database": "self.version", "cakephp/datasource": "self.version", @@ -75,6 +77,7 @@ "psr/http-factory-implementation": "^1.0", "psr/http-server-handler-implementation": "^1.0", "psr/http-server-middleware-implementation": "^1.0", + "psr/link-implementation": "^2.0", "psr/log-implementation": "^3.0", "psr/simple-cache-implementation": "^3.0" }, @@ -115,7 +118,10 @@ "Named\\": "tests/test_app/Plugin/Named/src/", "TestTheme\\": "tests/test_app/Plugin/TestTheme/src/", "PluginJs\\": "tests/test_app/Plugin/PluginJs/src/" - } + }, + "files": [ + "tests/TestCase/Container/Asset/function.php" + ] }, "scripts": { "check": [ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f1cd44d7092..1e227e7d093 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -51,7 +51,7 @@ parameters: - message: '#^Unsafe usage of new static\(\)\.$#' identifier: new.static - count: 8 + count: 9 path: src/Database/Expression/QueryExpression.php - diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c8e0d5af07a..0c9ada3f739 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -19,6 +19,7 @@ parameters: - identifier: method.internalClass - identifier: new.internalClass - identifier: trait.unused + - identifier: method.templateTypeNotInParameter - identifier: generics.interfaceConflict paths: diff --git a/src/Cache/composer.json b/src/Cache/composer.json index e0b42708510..d5c50625d15 100644 --- a/src/Cache/composer.json +++ b/src/Cache/composer.json @@ -23,8 +23,8 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0", - "cakephp/event": "^5.3.0", + "cakephp/core": "5.4.*@dev", + "cakephp/event": "5.4.*@dev", "psr/simple-cache": "^2.0 || ^3.0" }, "provide": { diff --git a/src/Collection/CollectionInterface.php b/src/Collection/CollectionInterface.php index e28527760e0..1575d8c61c9 100644 --- a/src/Collection/CollectionInterface.php +++ b/src/Collection/CollectionInterface.php @@ -26,6 +26,11 @@ * list of elements exposing a number of traversing and extracting method for * generating other collections. * + * @method \Cake\Collection\CollectionInterface keys() Returns a new collection containing only the keys of the elements. + * @method \Cake\Collection\CollectionInterface values() Returns a new collection containing only the values, re-indexed with consecutive integers. + * @method string implode(string $glue, callable|string|null $path = null) Concatenates all elements into a string using the provided glue. + * @method \Cake\Collection\CollectionInterface when(mixed $condition, callable $callback) Applies callback if condition is truthy. + * @method \Cake\Collection\CollectionInterface unless(mixed $condition, callable $callback) Applies callback if condition is falsy. * @template TKey * @template-covariant TValue * @template-extends \Iterator diff --git a/src/Collection/CollectionTrait.php b/src/Collection/CollectionTrait.php index 6712b3954a8..e150d23f6d3 100644 --- a/src/Collection/CollectionTrait.php +++ b/src/Collection/CollectionTrait.php @@ -1224,6 +1224,79 @@ public function countKeys(): int return count($this->toArray()); } + /** + * Returns a new collection containing only the keys of the elements. + * + * @return \Cake\Collection\CollectionInterface + */ + public function keys(): CollectionInterface + { + $generator = function (): Generator { + foreach ($this->optimizeUnwrap() as $key => $value) { + yield $key; + } + }; + + return $this->newCollection($generator()); + } + + /** + * Returns a new collection containing only the values, re-indexed with consecutive integers. + * + * @return \Cake\Collection\CollectionInterface + */ + public function values(): CollectionInterface + { + $generator = function (): Generator { + foreach ($this->optimizeUnwrap() as $value) { + yield $value; + } + }; + + return $this->newCollection($generator()); + } + + /** + * @inheritDoc + */ + public function implode(string $glue, callable|string|null $path = null): string + { + $items = $this; + if ($path !== null) { + $items = $items->extract($path); + } + + return implode($glue, $items->toList()); + } + + /** + * Applies callback if condition is truthy. + * + * @return \Cake\Collection\CollectionInterface + */ + public function when(mixed $condition, callable $callback): CollectionInterface + { + if ($condition) { + return $callback($this, $condition); + } + + return $this; + } + + /** + * Applies callback if condition is falsy. + * + * @return \Cake\Collection\CollectionInterface + */ + public function unless(mixed $condition, callable $callback): CollectionInterface + { + if (!$condition) { + return $callback($this, $condition); + } + + return $this; + } + /** * Unwraps this iterator and returns the simplest * traversable that can be used for getting the data out diff --git a/src/Command/Helper/BannerHelper.php b/src/Command/Helper/BannerHelper.php index 840f4464367..bea6ae43f8b 100644 --- a/src/Command/Helper/BannerHelper.php +++ b/src/Command/Helper/BannerHelper.php @@ -1,107 +1,9 @@ padding = $padding; - - return $this; - } - - /** - * Modify the padding of the helper - * - * @param string $style The style value to use. - * @return $this - */ - public function withStyle(string $style) - { - $this->style = $style; - - return $this; - } - - /** - * Output a banner - * - * @param array $args The messages to output - * @return void - */ - public function output(array $args): void - { - if ($args === []) { - throw new InvalidArgumentException('At least one argument is required'); - } - - $lengths = array_map(mb_strlen(...), $args); - $maxLength = max($lengths); - $bannerLength = $maxLength + $this->padding * 2; - $start = "<{$this->style}>"; - $end = "style}>"; - - $lines = [ - '', - $start . str_repeat(' ', $bannerLength) . $end, - ]; - foreach ($args as $line) { - $lineLength = mb_strlen($line); - $linePadding = (int)max($this->padding, $bannerLength - $lineLength - $this->padding); - - $lines[] = $start . - str_repeat(' ', $this->padding) . - $line . - str_repeat(' ', $linePadding) . - $end; - } - - $lines[] = $start . str_repeat(' ', $bannerLength) . $end; - $lines[] = ''; - - $this->_io->out($lines); - } -} +class_exists(BannerHelper::class); diff --git a/src/Command/Helper/ProgressHelper.php b/src/Command/Helper/ProgressHelper.php index f14d54c31ca..63689a88111 100644 --- a/src/Command/Helper/ProgressHelper.php +++ b/src/Command/Helper/ProgressHelper.php @@ -1,163 +1,9 @@ helper('Progress')->output(['callback' => function ($progress) { - * // Do work - * $progress->increment(); - * }]); - * ``` - */ -class ProgressHelper extends Helper -{ - /** - * Default value for progress bar total value. - * Percent completion is derived from progress/total - */ - protected const DEFAULT_TOTAL = 100; - - /** - * Default value for progress bar width - */ - protected const DEFAULT_WIDTH = 80; - - /** - * The current progress. - * - * @var float|int - */ - protected float|int $_progress = 0; - - /** - * The total number of 'items' to progress through. - * - * @var int - */ - protected int $_total = self::DEFAULT_TOTAL; - - /** - * The width of the bar. - * - * @var int - */ - protected int $_width = self::DEFAULT_WIDTH; - - /** - * Output a progress bar. - * - * Takes a number of options to customize the behavior: - * - * - `total` The total number of items in the progress bar. Defaults - * to 100. - * - `width` The width of the progress bar. Defaults to 80. - * - `callback` The callback that will be called in a loop to advance the progress bar. - * - * @param array $args The arguments/options to use when outputting the progress bar. - * @return void - */ - public function output(array $args): void - { - $args += ['callback' => null]; - if (isset($args[0])) { - $args['callback'] = $args[0]; - } - if (!$args['callback'] || !is_callable($args['callback'])) { - throw new InvalidArgumentException('Callback option must be a callable.'); - } - $this->init($args); - - $callback = $args['callback']; - - $this->_io->out('', 0); - while ($this->_progress < $this->_total) { - $callback($this); - $this->draw(); - } - $this->_io->out(''); - } - - /** - * Initialize the progress bar for use. - * - * - `total` The total number of items in the progress bar. Defaults - * to 100. - * - `width` The width of the progress bar. Defaults to 80. - * - * @param array $args The initialization data. - * @return $this - */ - public function init(array $args = []) - { - $args += ['total' => self::DEFAULT_TOTAL, 'width' => self::DEFAULT_WIDTH]; - $this->_progress = 0; - $this->_width = $args['width']; - $this->_total = $args['total']; - - return $this; - } - - /** - * Increment the progress bar. - * - * @param float|int $num The amount of progress to advance by. - * @return $this - */ - public function increment(float|int $num = 1) - { - $this->_progress = min(max(0, $this->_progress + $num), $this->_total); - - return $this; - } - - /** - * Render the progress bar based on the current state. - * - * @return $this - */ - public function draw() - { - $numberLen = strlen(' 100%'); - $complete = round($this->_progress / $this->_total, 2); - $barLen = ($this->_width - $numberLen) * $this->_progress / $this->_total; - $bar = ''; - if ($barLen > 1) { - $bar = str_repeat('=', (int)$barLen - 1) . '>'; - } - - $pad = ceil($this->_width - $numberLen - $barLen); - if ($pad > 0) { - $bar .= str_repeat(' ', (int)$pad); - } - $percent = ($complete * 100) . '%'; - $bar .= str_pad($percent, $numberLen, ' ', STR_PAD_LEFT); - - $this->_io->overwrite($bar, 0); - - return $this; - } -} +class_exists(ProgressHelper::class); diff --git a/src/Command/Helper/TableHelper.php b/src/Command/Helper/TableHelper.php index 741fa43fa7d..683cc6fc126 100644 --- a/src/Command/Helper/TableHelper.php +++ b/src/Command/Helper/TableHelper.php @@ -1,185 +1,9 @@ - */ - protected array $_defaultConfig = [ - 'headers' => true, - 'rowSeparator' => false, - 'headerStyle' => 'info', - ]; - - /** - * Calculate the column widths - * - * @param array $rows The rows on which the column's width will be calculated on. - * @return array - */ - protected function _calculateWidths(array $rows): array - { - $widths = []; - foreach ($rows as $line) { - foreach (array_values($line) as $k => $v) { - $columnLength = $this->_cellWidth((string)$v); - if ($columnLength >= ($widths[$k] ?? 0)) { - $widths[$k] = $columnLength; - } - } - } - - return $widths; - } - - /** - * Get the width of a cell exclusive of style tags. - * - * @param string $text The text to calculate a width for. - * @return int The width of the textual content in visible characters. - */ - protected function _cellWidth(string $text): int - { - if ($text === '') { - return 0; - } - - if (!str_contains($text, '<') && !str_contains($text, '>')) { - return mb_strwidth($text); - } - - $styles = $this->_io->styles(); - $tags = implode('|', array_keys($styles)); - $text = (string)preg_replace('##', '', $text); - - return mb_strwidth($text); - } - - /** - * Output a row separator. - * - * @param array $widths The widths of each column to output. - * @return void - */ - protected function _rowSeparator(array $widths): void - { - $out = ''; - foreach ($widths as $column) { - $out .= '+' . str_repeat('-', $column + 2); - } - $out .= '+'; - $this->_io->out($out); - } - - /** - * Output a row. - * - * @param array $row The row to output. - * @param array $widths The widths of each column to output. - * @param array $options Options to be passed. - * @return void - */ - protected function _render(array $row, array $widths, array $options = []): void - { - if ($row === []) { - return; - } - - $out = ''; - foreach (array_values($row) as $i => $column) { - $column = (string)$column; - $pad = $widths[$i] - $this->_cellWidth($column); - if (!empty($options['style'])) { - $column = $this->_addStyle($column, $options['style']); - } - if ($column !== '' && preg_match('#(.*).+(.*)#', $column, $matches)) { - if ($matches[1] !== '' || $matches[2] !== '') { - throw new UnexpectedValueException('You cannot include text before or after the text-right tag.'); - } - $column = str_replace(['', ''], '', $column); - $out .= '| ' . str_repeat(' ', $pad) . $column . ' '; - } else { - $out .= '| ' . $column . str_repeat(' ', $pad) . ' '; - } - } - $out .= '|'; - $this->_io->out($out); - } - - /** - * Output a table. - * - * Data will be output based on the order of the values - * in the array. The keys will not be used to align data. - * - * @param array $args The data to render out. - * @return void - */ - public function output(array $args): void - { - if (!$args) { - return; - } - - $this->_io->setStyle('text-right', ['text' => null]); - - $config = $this->getConfig(); - $widths = $this->_calculateWidths($args); - - $this->_rowSeparator($widths); - if ($config['headers'] === true) { - $this->_render(array_shift($args), $widths, ['style' => $config['headerStyle']]); - $this->_rowSeparator($widths); - } - - if (!$args) { - return; - } - - foreach ($args as $line) { - $this->_render($line, $widths); - if ($config['rowSeparator'] === true) { - $this->_rowSeparator($widths); - } - } - if ($config['rowSeparator'] !== true) { - $this->_rowSeparator($widths); - } - } - - /** - * Add style tags - * - * @param string $text The text to be surrounded - * @param string $style The style to be applied - * @return string - */ - protected function _addStyle(string $text, string $style): string - { - return '<' . $style . '>' . $text . ''; - } -} +class_exists(TableHelper::class); diff --git a/src/Command/Helper/TreeHelper.php b/src/Command/Helper/TreeHelper.php index 5820e4be014..fea9220376e 100644 --- a/src/Command/Helper/TreeHelper.php +++ b/src/Command/Helper/TreeHelper.php @@ -1,124 +1,9 @@ 0, - 'elementIndent' => 0, - ]; - - /** - * Outputs an array in tree form. - * - * @param array $args Tree array - * @return void - */ - public function output(array $args): void - { - $prefix = str_repeat(' ', $this->_config['baseIndent']); - $this->outputArray($args, $prefix, topLevel: true); - } - - /** - * Output an array in a tree. - * - * @param array $array - * @param string $prefix - * @param bool $topLevel - * @return void - */ - protected function outputArray(array $array, string $prefix, bool $topLevel): void - { - $i = 1; - $numValues = count($array); - $elementPrefix = $topLevel ? '' : str_repeat(' ', $this->_config['elementIndent']); - foreach ($array as $key => $value) { - $isLast = $i++ === $numValues; - $marker = $isLast ? '└── ' : '├── '; - $indent = $isLast ? ' ' : '│ '; - $this->outputElement($key, $value, $prefix . $elementPrefix, $marker, $indent); - } - } - - /** - * Output an array element. - * - * @param string|int $key - * @param mixed $value - * @param string $prefix - * @param string $marker - * @param string $indent - * @return void - */ - protected function outputElement( - string|int $key, - mixed $value, - string $prefix, - string $marker, - string $indent, - ): void { - if (is_array($value)) { - $this->_io->out($prefix . $marker . $key); - $this->outputArray($value, $prefix . $indent, topLevel: false); - } elseif (is_string($key)) { - $this->_io->out($prefix . $marker . $key); - $this->outputValue($value, $prefix . $indent . '└── '); - } else { - $this->outputValue($value, $prefix . $marker); - } - } - - /** - * Output a value in a tree. - * - * @param mixed $value - * @param string $prefix - * @return void - */ - protected function outputValue(mixed $value, string $prefix): void - { - if ($value instanceof Closure) { - $this->_io->out($prefix . $value()); - } elseif ($value instanceof EnumLabelInterface) { - $this->_io->out($prefix . $value->label()); - } elseif ($value instanceof BackedEnum) { - $this->_io->out($prefix . $value->value); - } elseif ($value instanceof UnitEnum) { - $this->_io->out($prefix . $value->name); - } elseif (is_bool($value)) { - $this->_io->out($prefix . ($value ? 'true' : 'false')); - } else { - $this->_io->out($prefix . $value); - } - } -} +class_exists(TreeHelper::class); diff --git a/src/Command/I18nExtractCommand.php b/src/Command/I18nExtractCommand.php index 31a66abd952..6dd75851b9c 100644 --- a/src/Command/I18nExtractCommand.php +++ b/src/Command/I18nExtractCommand.php @@ -16,15 +16,16 @@ */ namespace Cake\Command; -use Cake\Command\Helper\ProgressHelper; use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; +use Cake\Console\Helper\ProgressHelper; use Cake\Core\App; use Cake\Core\Configure; use Cake\Core\Exception\CakeException; use Cake\Core\Plugin; use Cake\Utility\Filesystem; +use Cake\Utility\Fs\Finder; use Cake\Utility\Inflector; /** @@ -847,15 +848,19 @@ protected function _searchFiles(): void continue; } $path .= DIRECTORY_SEPARATOR; - $fs = new Filesystem(); - $files = $fs->findRecursive($path, '/\.php$/'); - $files = array_keys(iterator_to_array($files)); - sort($files); - if ($pattern) { - $files = preg_grep($pattern, $files, PREG_GREP_INVERT) ?: []; - $files = array_values($files); + $files = (new Finder()) + ->in($path) + ->name('*.php') + ->files(); + foreach ($files as $file) { + $this->_files[] = $file->getPathname(); } - $this->_files = array_merge($this->_files, $files); + } + $this->_files = array_unique($this->_files); + sort($this->_files); + if ($pattern) { + $this->_files = preg_grep($pattern, $this->_files, PREG_GREP_INVERT) ?: []; + $this->_files = array_values($this->_files); } $this->_files = array_unique($this->_files); } diff --git a/src/Command/PluginAssetsCopyCommand.php b/src/Command/PluginAssetsCopyCommand.php index 2221e374e17..c418c80fd46 100644 --- a/src/Command/PluginAssetsCopyCommand.php +++ b/src/Command/PluginAssetsCopyCommand.php @@ -55,9 +55,6 @@ public static function getDescription(): string */ public function execute(Arguments $args, ConsoleIo $io): ?int { - $this->io = $io; - $this->args = $args; - $name = $args->getArgument('name'); $overwrite = (bool)$args->getOption('overwrite'); $this->_process($this->_list($name), true, $overwrite); diff --git a/src/Command/PluginAssetsRemoveCommand.php b/src/Command/PluginAssetsRemoveCommand.php index b0d613b2e27..335757b0921 100644 --- a/src/Command/PluginAssetsRemoveCommand.php +++ b/src/Command/PluginAssetsRemoveCommand.php @@ -54,9 +54,6 @@ public static function getDescription(): string */ public function execute(Arguments $args, ConsoleIo $io): ?int { - $this->io = $io; - $this->args = $args; - $name = $args->getArgument('name'); $plugins = $this->_list($name); diff --git a/src/Command/PluginAssetsSymlinkCommand.php b/src/Command/PluginAssetsSymlinkCommand.php index cf2ed4bc91f..f4714a79fac 100644 --- a/src/Command/PluginAssetsSymlinkCommand.php +++ b/src/Command/PluginAssetsSymlinkCommand.php @@ -56,9 +56,6 @@ public static function getDescription(): string */ public function execute(Arguments $args, ConsoleIo $io): ?int { - $this->io = $io; - $this->args = $args; - $name = $args->getArgument('name'); $overwrite = (bool)$args->getOption('overwrite'); $relative = (bool)$args->getOption('relative'); diff --git a/src/Command/PluginAssetsTrait.php b/src/Command/PluginAssetsTrait.php index 267a7acefa9..d9d4574ec4b 100644 --- a/src/Command/PluginAssetsTrait.php +++ b/src/Command/PluginAssetsTrait.php @@ -16,8 +16,6 @@ */ namespace Cake\Command; -use Cake\Console\Arguments; -use Cake\Console\ConsoleIo; use Cake\Core\Configure; use Cake\Core\Plugin; use Cake\Utility\Filesystem; @@ -31,20 +29,6 @@ */ trait PluginAssetsTrait { - /** - * Arguments - * - * @var \Cake\Console\Arguments - */ - protected Arguments $args; - - /** - * Console IO - * - * @var \Cake\Console\ConsoleIo - */ - protected ConsoleIo $io; - /** * Get list of plugins to process. Plugins without a webroot directory are skipped. * diff --git a/src/Console/BaseCommand.php b/src/Console/BaseCommand.php index 7cb40cff579..6687600c16e 100644 --- a/src/Console/BaseCommand.php +++ b/src/Console/BaseCommand.php @@ -61,6 +61,10 @@ abstract class BaseCommand implements CommandInterface, EventDispatcherInterface */ protected string $name = 'cake unknown'; + protected Arguments $args; + + protected ConsoleIo $io; + protected ?CommandFactoryInterface $factory = null; /** @@ -170,8 +174,9 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption * Hook method invoked by CakePHP when a command is about to be executed. * * Override this method and implement expensive/important setup steps that - * should not run on every command run. This method will be called *before* - * the options and arguments are validated and processed. + * should not run on every command run. This method will be called *after* + * the options and arguments are validated and processed, so `$this->args` + * and `$this->io` are both available. * * @return void */ @@ -227,7 +232,7 @@ public function afterExecute(EventInterface $event, Arguments $args, ConsoleIo $ */ public function run(array $argv, ConsoleIo $io): ?int { - $this->initialize(); + $this->io = $io; $parser = $this->getOptionParser(); try { @@ -242,7 +247,10 @@ public function run(array $argv, ConsoleIo $io): ?int return static::CODE_ERROR; } + $this->args = $args; + $this->setOutputLevel($args, $io); + $this->initialize(); if ($args->getOption('help')) { $this->displayHelp($parser, $args, $io); diff --git a/src/Console/Command/HelpCommand.php b/src/Console/Command/HelpCommand.php index 366d09b69a5..b1613c787b4 100644 --- a/src/Console/Command/HelpCommand.php +++ b/src/Console/Command/HelpCommand.php @@ -33,7 +33,7 @@ /** * Print out command list */ -class HelpCommand extends BaseCommand implements CommandCollectionAwareInterface +class HelpCommand extends BaseCommand implements CommandCollectionAwareInterface, CommandHiddenInterface { /** * The command collection to get help on. @@ -42,6 +42,13 @@ class HelpCommand extends BaseCommand implements CommandCollectionAwareInterface */ protected CommandCollection $commands; + /** + * The header line rendered above command listings. + * + * @var string|null + */ + protected ?string $headerLine = null; + /** * @inheritDoc */ @@ -50,6 +57,17 @@ public function setCommandCollection(CommandCollection $commands): void $this->commands = $commands; } + /** + * Set the header line rendered above command listings. + * + * @param string $headerLine Header text including optional console markup. + * @return void + */ + public function setHeaderLine(string $headerLine): void + { + $this->headerLine = $headerLine; + } + /** * Main function Prints out the list of commands. * @@ -143,10 +161,12 @@ protected function asText(ConsoleIo $io, iterable $commands, bool $verbose = fal } sort($commandList); + $headerLine = $this->getHeaderLine(); + if ($headerLine !== '') { + $io->out($headerLine, 2); + } + if ($verbose) { - $version = Configure::version(); - $debug = Configure::read('debug') ? 'true' : 'false'; - $io->out("CakePHP: {$version} (debug: {$debug})", 2); $this->outputPaths($io); $this->outputGrouped($io, $invert); } else { @@ -164,6 +184,27 @@ protected function asText(ConsoleIo $io, iterable $commands, bool $verbose = fal } } + /** + * Get the help output header line. + * + * @return string + */ + protected function getHeaderLine(): string + { + if ($this->headerLine !== null) { + return $this->headerLine; + } + + $version = Configure::version(); + if ($version === 'unknown') { + return ''; + } + + $debug = Configure::read('debug') ? 'true' : 'false'; + + return "CakePHP: {$version} (debug: {$debug})"; + } + /** * Output commands grouped by plugin/namespace (verbose mode). * @@ -212,7 +253,7 @@ protected function outputGrouped(ConsoleIo $io, array $invert): void $io->out("{$prefix}:"); sort($names); foreach ($names as $data) { - $io->out(' - ' . $data['name']); + $io->out(' - ' . $data['name'] . ''); if ($data['description']) { $io->info(str_pad(" \u{2514}", 13, "\u{2500}") . ' ' . $data['description']); } @@ -263,7 +304,7 @@ protected function outputCompactCommands(ConsoleIo $io, array $commands): void foreach ($commands as $data) { $maxNameLength = max($maxNameLength, strlen($data['name'])); } - $nameColumnWidth = $maxNameLength + 3; + $nameColumnWidth = max($maxNameLength + 5, 17); // Output single commands under "Available Commands:" header $isFirst = true; @@ -272,7 +313,7 @@ protected function outputCompactCommands(ConsoleIo $io, array $commands): void foreach ($singleCommands as $prefix => $cmd) { $description = $cmd['description']; $padding = str_repeat(' ', $nameColumnWidth - 2 - strlen($prefix)); - $linePrefix = ' ' . $prefix . '' . $padding; + $linePrefix = ' ' . $prefix . '' . $padding; if ($description !== '') { $description = strtok($description, "\n"); @@ -296,7 +337,7 @@ protected function outputCompactCommands(ConsoleIo $io, array $commands): void $description = $cmd['description']; $padding = str_repeat(' ', $nameColumnWidth - 2 - strlen($fullName)); - $linePrefix = ' ' . $fullName . '' . $padding; + $linePrefix = ' ' . $fullName . '' . $padding; if ($description !== '') { $description = strtok($description, "\n"); diff --git a/src/Console/CommandRunner.php b/src/Console/CommandRunner.php index e1b3c40149f..4d6c2eaf3cb 100644 --- a/src/Console/CommandRunner.php +++ b/src/Console/CommandRunner.php @@ -21,6 +21,7 @@ use Cake\Console\Exception\MissingOptionException; use Cake\Console\Exception\StopException; use Cake\Core\ConsoleApplicationInterface; +use Cake\Core\ConsoleHelpHeaderProviderInterface; use Cake\Core\ContainerApplicationInterface; use Cake\Core\EventAwareApplicationInterface; use Cake\Core\PluginApplicationInterface; @@ -262,6 +263,10 @@ protected function getCommand(ConsoleIo $io, CommandCollection $commands, string $instance = $this->createCommand($instance); } + if ($instance instanceof HelpCommand && $this->app instanceof ConsoleHelpHeaderProviderInterface) { + $instance->setHeaderLine($this->app->getConsoleHelpHeader()); + } + $instance->setName("{$this->root} {$name}"); if ($instance instanceof CommandCollectionAwareInterface) { @@ -320,7 +325,6 @@ protected function longestCommandName(CommandCollection $commands, array $argv): protected function resolveName(CommandCollection $commands, ConsoleIo $io, ?string $name): string { if (!$name) { - $io->error('No command provided. Choose one of the available commands.', 2); $name = 'help'; } $name = $this->aliases[$name] ?? $name; diff --git a/src/Console/CommandScanner.php b/src/Console/CommandScanner.php index 3dd77883830..dadc7c35628 100644 --- a/src/Console/CommandScanner.php +++ b/src/Console/CommandScanner.php @@ -19,7 +19,7 @@ use Cake\Core\App; use Cake\Core\Configure; use Cake\Core\Plugin; -use Cake\Utility\Filesystem; +use Cake\Utility\Fs\Finder; use Cake\Utility\Inflector; use ReflectionClass; @@ -101,9 +101,12 @@ protected function scanDir(string $path, string $namespace, string $prefix, arra $hide[] = ''; $classPattern = '/Command\.php$/'; - $fs = new Filesystem(); /** @var \Iterator<\SplFileInfo> $files */ - $files = $fs->find($path, $classPattern); + $files = (new Finder()) + ->in($path) + ->recursive(false) + ->name('*Command.php') + ->files(); $commands = []; foreach ($files as $fileInfo) { diff --git a/src/Console/Helper/BannerHelper.php b/src/Console/Helper/BannerHelper.php new file mode 100644 index 00000000000..d34e26aeaff --- /dev/null +++ b/src/Console/Helper/BannerHelper.php @@ -0,0 +1,111 @@ +padding = $padding; + + return $this; + } + + /** + * Modify the padding of the helper + * + * @param string $style The style value to use. + * @return $this + */ + public function withStyle(string $style) + { + $this->style = $style; + + return $this; + } + + /** + * Output a banner + * + * @param array $args The messages to output + * @return void + */ + public function output(array $args): void + { + if ($args === []) { + throw new InvalidArgumentException('At least one argument is required'); + } + + $lengths = array_map(mb_strlen(...), $args); + $maxLength = max($lengths); + $bannerLength = $maxLength + $this->padding * 2; + $start = "<{$this->style}>"; + $end = "style}>"; + + $lines = [ + '', + $start . str_repeat(' ', $bannerLength) . $end, + ]; + foreach ($args as $line) { + $lineLength = mb_strlen($line); + $linePadding = (int)max($this->padding, $bannerLength - $lineLength - $this->padding); + + $lines[] = $start . + str_repeat(' ', $this->padding) . + $line . + str_repeat(' ', $linePadding) . + $end; + } + + $lines[] = $start . str_repeat(' ', $bannerLength) . $end; + $lines[] = ''; + + $this->_io->out($lines); + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\BannerHelper', 'Cake\Command\Helper\BannerHelper'); +// phpcs:enable diff --git a/src/Console/Helper/ProgressHelper.php b/src/Console/Helper/ProgressHelper.php new file mode 100644 index 00000000000..1d9077f28a5 --- /dev/null +++ b/src/Console/Helper/ProgressHelper.php @@ -0,0 +1,167 @@ +helper('Progress')->output(['callback' => function ($progress) { + * // Do work + * $progress->increment(); + * }]); + * ``` + */ +class ProgressHelper extends Helper +{ + /** + * Default value for progress bar total value. + * Percent completion is derived from progress/total + */ + protected const DEFAULT_TOTAL = 100; + + /** + * Default value for progress bar width + */ + protected const DEFAULT_WIDTH = 80; + + /** + * The current progress. + * + * @var float|int + */ + protected float|int $_progress = 0; + + /** + * The total number of 'items' to progress through. + * + * @var int + */ + protected int $_total = self::DEFAULT_TOTAL; + + /** + * The width of the bar. + * + * @var int + */ + protected int $_width = self::DEFAULT_WIDTH; + + /** + * Output a progress bar. + * + * Takes a number of options to customize the behavior: + * + * - `total` The total number of items in the progress bar. Defaults + * to 100. + * - `width` The width of the progress bar. Defaults to 80. + * - `callback` The callback that will be called in a loop to advance the progress bar. + * + * @param array $args The arguments/options to use when outputting the progress bar. + * @return void + */ + public function output(array $args): void + { + $args += ['callback' => null]; + if (isset($args[0])) { + $args['callback'] = $args[0]; + } + if (!$args['callback'] || !is_callable($args['callback'])) { + throw new InvalidArgumentException('Callback option must be a callable.'); + } + $this->init($args); + + $callback = $args['callback']; + + $this->_io->out('', 0); + while ($this->_progress < $this->_total) { + $callback($this); + $this->draw(); + } + $this->_io->out(''); + } + + /** + * Initialize the progress bar for use. + * + * - `total` The total number of items in the progress bar. Defaults + * to 100. + * - `width` The width of the progress bar. Defaults to 80. + * + * @param array $args The initialization data. + * @return $this + */ + public function init(array $args = []) + { + $args += ['total' => self::DEFAULT_TOTAL, 'width' => self::DEFAULT_WIDTH]; + $this->_progress = 0; + $this->_width = $args['width']; + $this->_total = $args['total']; + + return $this; + } + + /** + * Increment the progress bar. + * + * @param float|int $num The amount of progress to advance by. + * @return $this + */ + public function increment(float|int $num = 1) + { + $this->_progress = min(max(0, $this->_progress + $num), $this->_total); + + return $this; + } + + /** + * Render the progress bar based on the current state. + * + * @return $this + */ + public function draw() + { + $numberLen = strlen(' 100%'); + $complete = round($this->_progress / $this->_total, 2); + $barLen = ($this->_width - $numberLen) * $this->_progress / $this->_total; + $bar = ''; + if ($barLen > 1) { + $bar = str_repeat('=', (int)$barLen - 1) . '>'; + } + + $pad = ceil($this->_width - $numberLen - $barLen); + if ($pad > 0) { + $bar .= str_repeat(' ', (int)$pad); + } + $percent = ($complete * 100) . '%'; + $bar .= str_pad($percent, $numberLen, ' ', STR_PAD_LEFT); + + $this->_io->overwrite($bar, 0); + + return $this; + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\ProgressHelper', 'Cake\Command\Helper\ProgressHelper'); +// phpcs:enable diff --git a/src/Console/Helper/TableHelper.php b/src/Console/Helper/TableHelper.php new file mode 100644 index 00000000000..6d2606a5c5c --- /dev/null +++ b/src/Console/Helper/TableHelper.php @@ -0,0 +1,189 @@ + + */ + protected array $_defaultConfig = [ + 'headers' => true, + 'rowSeparator' => false, + 'headerStyle' => 'info', + ]; + + /** + * Calculate the column widths + * + * @param array $rows The rows on which the column's width will be calculated on. + * @return array + */ + protected function _calculateWidths(array $rows): array + { + $widths = []; + foreach ($rows as $line) { + foreach (array_values($line) as $k => $v) { + $columnLength = $this->_cellWidth((string)$v); + if ($columnLength >= ($widths[$k] ?? 0)) { + $widths[$k] = $columnLength; + } + } + } + + return $widths; + } + + /** + * Get the width of a cell exclusive of style tags. + * + * @param string $text The text to calculate a width for. + * @return int The width of the textual content in visible characters. + */ + protected function _cellWidth(string $text): int + { + if ($text === '') { + return 0; + } + + if (!str_contains($text, '<') && !str_contains($text, '>')) { + return mb_strwidth($text); + } + + $styles = $this->_io->styles(); + $tags = implode('|', array_keys($styles)); + $text = (string)preg_replace('##', '', $text); + + return mb_strwidth($text); + } + + /** + * Output a row separator. + * + * @param array $widths The widths of each column to output. + * @return void + */ + protected function _rowSeparator(array $widths): void + { + $out = ''; + foreach ($widths as $column) { + $out .= '+' . str_repeat('-', $column + 2); + } + $out .= '+'; + $this->_io->out($out); + } + + /** + * Output a row. + * + * @param array $row The row to output. + * @param array $widths The widths of each column to output. + * @param array $options Options to be passed. + * @return void + */ + protected function _render(array $row, array $widths, array $options = []): void + { + if ($row === []) { + return; + } + + $out = ''; + foreach (array_values($row) as $i => $column) { + $column = (string)$column; + $pad = $widths[$i] - $this->_cellWidth($column); + if (!empty($options['style'])) { + $column = $this->_addStyle($column, $options['style']); + } + if ($column !== '' && preg_match('#(.*).+(.*)#', $column, $matches)) { + if ($matches[1] !== '' || $matches[2] !== '') { + throw new UnexpectedValueException('You cannot include text before or after the text-right tag.'); + } + $column = str_replace(['', ''], '', $column); + $out .= '| ' . str_repeat(' ', $pad) . $column . ' '; + } else { + $out .= '| ' . $column . str_repeat(' ', $pad) . ' '; + } + } + $out .= '|'; + $this->_io->out($out); + } + + /** + * Output a table. + * + * Data will be output based on the order of the values + * in the array. The keys will not be used to align data. + * + * @param array $args The data to render out. + * @return void + */ + public function output(array $args): void + { + if (!$args) { + return; + } + + $this->_io->setStyle('text-right', ['text' => null]); + + $config = $this->getConfig(); + $widths = $this->_calculateWidths($args); + + $this->_rowSeparator($widths); + if ($config['headers'] === true) { + $this->_render(array_shift($args), $widths, ['style' => $config['headerStyle']]); + $this->_rowSeparator($widths); + } + + if (!$args) { + return; + } + + foreach ($args as $line) { + $this->_render($line, $widths); + if ($config['rowSeparator'] === true) { + $this->_rowSeparator($widths); + } + } + if ($config['rowSeparator'] !== true) { + $this->_rowSeparator($widths); + } + } + + /** + * Add style tags + * + * @param string $text The text to be surrounded + * @param string $style The style to be applied + * @return string + */ + protected function _addStyle(string $text, string $style): string + { + return '<' . $style . '>' . $text . ''; + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\TableHelper', 'Cake\Command\Helper\TableHelper'); +// phpcs:enable diff --git a/src/Console/Helper/TreeHelper.php b/src/Console/Helper/TreeHelper.php new file mode 100644 index 00000000000..c6ea47ba428 --- /dev/null +++ b/src/Console/Helper/TreeHelper.php @@ -0,0 +1,128 @@ + 0, + 'elementIndent' => 0, + ]; + + /** + * Outputs an array in tree form. + * + * @param array $args Tree array + * @return void + */ + public function output(array $args): void + { + $prefix = str_repeat(' ', $this->_config['baseIndent']); + $this->outputArray($args, $prefix, topLevel: true); + } + + /** + * Output an array in a tree. + * + * @param array $array + * @param string $prefix + * @param bool $topLevel + * @return void + */ + protected function outputArray(array $array, string $prefix, bool $topLevel): void + { + $i = 1; + $numValues = count($array); + $elementPrefix = $topLevel ? '' : str_repeat(' ', $this->_config['elementIndent']); + foreach ($array as $key => $value) { + $isLast = $i++ === $numValues; + $marker = $isLast ? '└── ' : '├── '; + $indent = $isLast ? ' ' : '│ '; + $this->outputElement($key, $value, $prefix . $elementPrefix, $marker, $indent); + } + } + + /** + * Output an array element. + * + * @param string|int $key + * @param mixed $value + * @param string $prefix + * @param string $marker + * @param string $indent + * @return void + */ + protected function outputElement( + string|int $key, + mixed $value, + string $prefix, + string $marker, + string $indent, + ): void { + if (is_array($value)) { + $this->_io->out($prefix . $marker . $key); + $this->outputArray($value, $prefix . $indent, topLevel: false); + } elseif (is_string($key)) { + $this->_io->out($prefix . $marker . $key); + $this->outputValue($value, $prefix . $indent . '└── '); + } else { + $this->outputValue($value, $prefix . $marker); + } + } + + /** + * Output a value in a tree. + * + * @param mixed $value + * @param string $prefix + * @return void + */ + protected function outputValue(mixed $value, string $prefix): void + { + if ($value instanceof Closure) { + $this->_io->out($prefix . $value()); + } elseif (interface_exists(EnumLabelInterface::class) && $value instanceof EnumLabelInterface) { + $this->_io->out($prefix . $value->label()); + } elseif ($value instanceof BackedEnum) { + $this->_io->out($prefix . $value->value); + } elseif ($value instanceof UnitEnum) { + $this->_io->out($prefix . $value->name); + } elseif (is_bool($value)) { + $this->_io->out($prefix . ($value ? 'true' : 'false')); + } else { + $this->_io->out($prefix . $value); + } + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\TreeHelper', 'Cake\Command\Helper\TreeHelper'); +// phpcs:enable diff --git a/src/Console/HelperRegistry.php b/src/Console/HelperRegistry.php index dc6be8b042a..e3672c684c2 100644 --- a/src/Console/HelperRegistry.php +++ b/src/Console/HelperRegistry.php @@ -19,6 +19,7 @@ use Cake\Console\Exception\MissingHelperException; use Cake\Core\App; use Cake\Core\ObjectRegistry; +use function Cake\Core\deprecationWarning; /** * Registry for Helpers. Provides features @@ -56,8 +57,25 @@ public function setIo(ConsoleIo $io): void */ protected function _resolveClassName(string $class): ?string { - /** @var class-string<\Cake\Console\Helper>|null */ - return App::className($class, 'Command/Helper', 'Helper'); + /** @var class-string<\Cake\Console\Helper>|null $result */ + $result = App::className($class, 'Console/Helper', 'Helper'); + if ($result !== null) { + return $result; + } + + /** @var class-string<\Cake\Console\Helper>|null $result */ + $result = App::className($class, 'Command/Helper', 'Helper'); + if ($result !== null) { + deprecationWarning( + '5.4.0', + sprintf( + 'Helpers in `Command/Helper` are deprecated. Move `%s` to `Console/Helper`.', + $class, + ), + ); + } + + return $result; } /** diff --git a/src/Console/composer.json b/src/Console/composer.json index e66ee881434..c00fbd72c9f 100644 --- a/src/Console/composer.json +++ b/src/Console/composer.json @@ -24,10 +24,10 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0", - "cakephp/event": "^5.3.0", - "cakephp/log": "^5.3.0", - "cakephp/utility": "^5.3.0" + "cakephp/core": "5.4.*@dev", + "cakephp/event": "5.4.*@dev", + "cakephp/log": "5.4.*@dev", + "cakephp/utility": "5.4.*@dev" }, "suggest": { "cakephp/datasource": "To use the Command base classes", diff --git a/src/Container/Argument/ArgumentInterface.php b/src/Container/Argument/ArgumentInterface.php new file mode 100644 index 00000000000..ab592c43524 --- /dev/null +++ b/src/Container/Argument/ArgumentInterface.php @@ -0,0 +1,12 @@ + $arguments + * @return array + */ + public function resolveArguments(array $arguments): array; + + /** + * @param \ReflectionFunctionAbstract $method + * @param array $args + * @return array + */ + public function reflectArguments(ReflectionFunctionAbstract $method, array $args = []): array; +} diff --git a/src/Container/Argument/ArgumentResolverTrait.php b/src/Container/Argument/ArgumentResolverTrait.php new file mode 100644 index 00000000000..049c9061ea5 --- /dev/null +++ b/src/Container/Argument/ArgumentResolverTrait.php @@ -0,0 +1,120 @@ +getContainer(); + } catch (ContainerException) { + $container = $this instanceof ReflectionContainer ? $this : null; + } + + foreach ($arguments as &$arg) { + // if we have a literal, we don't want to do anything more with it + if ($arg instanceof LiteralArgumentInterface) { + $arg = $arg->getValue(); + continue; + } + + if ($arg instanceof ArgumentInterface) { + $argValue = $arg->getValue(); + } else { + $argValue = $arg; + } + + if (!is_string($argValue)) { + continue; + } + + // resolve the argument from the container, if it happens to be another + // argument wrapper, use that value + if ($container instanceof ContainerInterface && $container->has($argValue)) { + try { + $arg = $container->get($argValue); + + if ($arg instanceof ArgumentInterface) { + $arg = $arg->getValue(); + } + + continue; + } catch (NotFoundException) { + } + } + + // if we have a default value, we use that, no more resolution as + // we expect a default/optional argument value to be literal + if ($arg instanceof DefaultValueInterface) { + $arg = $arg->getDefaultValue(); + } + } + + return $arguments; + } + + /** + * @inheritDoc + */ + public function reflectArguments(ReflectionFunctionAbstract $method, array $args = []): array + { + $params = $method->getParameters(); + $arguments = []; + + foreach ($params as $param) { + $name = $param->getName(); + + // if we've been given a value for the argument, treat as literal + if (array_key_exists($name, $args)) { + $arguments[] = new LiteralArgument($args[$name]); + continue; + } + + $type = $param->getType(); + + if ($type instanceof ReflectionNamedType) { + // in PHP 8, nullable arguments have "?" prefix + $typeHint = ltrim($type->getName(), '?'); + + if ($param->isDefaultValueAvailable()) { + $arguments[] = new DefaultValueArgument($typeHint, $param->getDefaultValue()); + continue; + } + + $arguments[] = new ResolvableArgument($typeHint); + continue; + } + + if ($param->isDefaultValueAvailable()) { + $arguments[] = new LiteralArgument($param->getDefaultValue()); + continue; + } + + throw new NotFoundException(sprintf( + 'Unable to resolve a value for parameter (%s) in the function/method (%s)', + $name, + $method->getName(), + )); + } + + return $this->resolveArguments($arguments); + } + + /** + * @inheritDoc + */ + abstract public function getContainer(): DefinitionContainerInterface; +} diff --git a/src/Container/Argument/DefaultValueArgument.php b/src/Container/Argument/DefaultValueArgument.php new file mode 100644 index 00000000000..8616c870c80 --- /dev/null +++ b/src/Container/Argument/DefaultValueArgument.php @@ -0,0 +1,27 @@ +defaultValue = $defaultValue; + parent::__construct($value); + } + + /** + * @inheritDoc + */ + public function getDefaultValue(): mixed + { + return $this->defaultValue; + } +} diff --git a/src/Container/Argument/DefaultValueInterface.php b/src/Container/Argument/DefaultValueInterface.php new file mode 100644 index 00000000000..bfc764060b9 --- /dev/null +++ b/src/Container/Argument/DefaultValueInterface.php @@ -0,0 +1,12 @@ +value = $value; + } else { + throw new InvalidArgumentException('Incorrect type for value.'); + } + } + + /** + * @inheritDoc + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/src/Container/Argument/LiteralArgumentInterface.php b/src/Container/Argument/LiteralArgumentInterface.php new file mode 100644 index 00000000000..a0238d0a6b0 --- /dev/null +++ b/src/Container/Argument/LiteralArgumentInterface.php @@ -0,0 +1,8 @@ +value = $value; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } +} diff --git a/src/Container/Argument/ResolvableArgumentInterface.php b/src/Container/Argument/ResolvableArgumentInterface.php new file mode 100644 index 00000000000..741e5864512 --- /dev/null +++ b/src/Container/Argument/ResolvableArgumentInterface.php @@ -0,0 +1,12 @@ + + */ + protected array $delegates = []; + + /** + * @param \Cake\Container\Definition\DefinitionAggregateInterface $definitions + * @param \Cake\Container\ServiceProvider\ServiceProviderAggregateInterface $providers + * @param \Cake\Container\Inflector\InflectorAggregateInterface $inflectors + */ + public function __construct( + protected DefinitionAggregateInterface $definitions = new DefinitionAggregate(), + protected ServiceProviderAggregateInterface $providers = new ServiceProviderAggregate(), + protected InflectorAggregateInterface $inflectors = new InflectorAggregate(), + ) { + $this->definitions->setContainer($this); + $this->providers->setContainer($this); + $this->inflectors->setContainer($this); + + $this->enableAutoWiring(); + } + + /** + * @inheritDoc + */ + public function add(string $id, $concrete = null): DefinitionInterface + { + $concrete ??= $id; + + if ($this->defaultToShared) { + return $this->addShared($id, $concrete); + } + + return $this->definitions->add($id, $concrete); + } + + /** + * @inheritDoc + */ + public function addShared(string $id, $concrete = null): DefinitionInterface + { + $concrete ??= $id; + + return $this->definitions->addShared($id, $concrete); + } + + /** + * Add multiple definitions at once. + * + * Examples: + * + * ``` + * $container->addDefinitions([ + * Foo::class, + * Bar::class + * ]); + * ``` + * + * ``` + * $container->addDefinitions([ + * Foo::class => [Bar::class], + * Bar::class + * ]); + * ``` + * + * ``` + * $container->addDefinitions([ + * 'foo' => Foo::class, + * 'bar' => Bar::class + * ]); + * ``` + * + * @param array|class-string> $definitions + * @return \Cake\Container\DefinitionContainerInterface + */ + public function addDefinitions(array $definitions): DefinitionContainerInterface + { + foreach ($definitions as $id => $definition) { + if (is_int($id) && is_string($definition)) { + $this->add($definition); + } elseif (is_string($id) && is_string($definition)) { + $this->add($id, $definition); + } elseif (is_string($id) && is_array($definition)) { + $this->add($id) + ->addArguments($definition); + } + } + + return $this; + } + + /** + * @param bool $shared + * @return \Psr\Container\ContainerInterface + */ + public function defaultToShared(bool $shared = true): ContainerInterface + { + $this->defaultToShared = $shared; + + return $this; + } + + /** + * @inheritDoc + */ + public function extend(string $id): DefinitionInterface + { + if ($this->providers->provides($id)) { + $this->providers->register($id); + } + + if ($this->definitions->has($id)) { + return $this->definitions->getDefinition($id); + } + + throw new NotFoundException(sprintf( + 'Unable to extend alias (%s) as it is not being managed as a definition', + $id, + )); + } + + /** + * @inheritDoc + */ + public function addServiceProvider(ServiceProviderInterface $provider): DefinitionContainerInterface + { + $this->providers->add($provider); + + return $this; + } + + /** + * @template RequestedType + * @param class-string|string $id + * @return RequestedType|mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function get(string $id) + { + return $this->resolve($id); + } + + /** + * @template RequestedType + * @param class-string|string $id + * @return RequestedType|mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function getNew(mixed $id): mixed + { + return $this->resolve($id, true); + } + + /** + * Resolve an entry with specific constructor arguments. + * + * Unlike `get()`, this method allows passing specific constructor arguments + * that will be used during autowiring. Arguments can be passed by name. + * + * Example: + * ``` + * $container->make(MyService::class, ['configValue' => 'foo']); + * ``` + * + * @template RequestedType + * @param class-string|string $id + * @param array $args Named arguments to pass to the constructor + * @return RequestedType|mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function make(string $id, array $args = []): mixed + { + return $this->resolve($id, true, $args); + } + + /** + * @inheritDoc + */ + public function has($id): bool + { + if ($this->definitions->has($id)) { + return true; + } + + if ($this->definitions->hasTag($id)) { + return true; + } + + if ($this->providers->provides($id)) { + return true; + } + + foreach ($this->delegates as $delegate) { + if ($delegate->has($id)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasDefinition(string $id): bool + { + return $this->definitions->has($id); + } + + /** + * @inheritDoc + */ + public function inflector(string $type, ?callable $callback = null): InflectorInterface + { + return $this->inflectors->add($type, $callback); + } + + /** + * @param \Psr\Container\ContainerInterface $container + * @return $this + */ + public function delegate(ContainerInterface $container) + { + $this->delegates[] = $container; + + if ($container instanceof ContainerAwareInterface) { + $container->setContainer($this); + } + + return $this; + } + + /** + * @param bool $cache + * @return void + */ + public function enableAutoWiring(bool $cache = true): void + { + $this->delegate(new ReflectionContainer($cache)); + } + + /** + * @return void + */ + public function disableAutoWiring(): void + { + $this->delegates = []; + } + + /** + * @param mixed $id + * @param bool $new + * @param array $args + * @return mixed|object|array|null|void + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + protected function resolve(mixed $id, bool $new = false, array $args = []): mixed + { + if ($this->definitions->has($id)) { + $resolved = $new ? $this->definitions->resolveNew($id) : $this->definitions->resolve($id); + + return $this->inflectors->inflect($resolved); + } + + if ($this->definitions->hasTag($id)) { + $arrayOf = $new + ? $this->definitions->resolveTaggedNew($id) + : $this->definitions->resolveTagged($id); + + array_walk($arrayOf, function (object &$resolved): void { + $resolved = $this->inflectors->inflect($resolved); + }); + + return $arrayOf; + } + + if ($this->providers->provides($id)) { + $this->providers->register($id); + + if (!$this->definitions->has($id) && !$this->definitions->hasTag($id)) { + throw new ContainerException(sprintf('Service provider lied about providing (%s) service', $id)); + } + + return $this->resolve($id, $new, $args); + } + + foreach ($this->delegates as $delegate) { + if ($delegate->has($id)) { + // Use getNew() for ReflectionContainer when $new is true or args are provided + if ($delegate instanceof ReflectionContainer) { + $resolved = $new || $args !== [] + ? $delegate->getNew($id, $args) + : $delegate->get($id, $args); + } else { + $resolved = $delegate->get($id); + } + + return $this->inflectors->inflect($resolved); + } + } + + throw new NotFoundException(sprintf('Alias (%s) is not being managed by the container or delegates', $id)); + } +} diff --git a/src/Container/ContainerAwareInterface.php b/src/Container/ContainerAwareInterface.php new file mode 100644 index 00000000000..4817cc83ed7 --- /dev/null +++ b/src/Container/ContainerAwareInterface.php @@ -0,0 +1,18 @@ +container = $container; + + if ($this instanceof ContainerAwareInterface) { + return $this; + } + + throw new BadMethodCallException(sprintf( + 'Attempt to use (%s) while not implementing (%s)', + ContainerAwareTrait::class, + ContainerAwareInterface::class, + )); + } + + /** + * @inheritDoc + */ + public function getContainer(): DefinitionContainerInterface + { + if ($this->container instanceof DefinitionContainerInterface) { + return $this->container; + } + + throw new ContainerException('No container implementation has been set.'); + } +} diff --git a/src/Container/Definition/Definition.php b/src/Container/Definition/Definition.php new file mode 100644 index 00000000000..ebf7f2b7410 --- /dev/null +++ b/src/Container/Definition/Definition.php @@ -0,0 +1,344 @@ +alias = $id; + $this->concrete = $concrete; + } + + /** + * @inheritDoc + */ + public function addTag(string $tag): DefinitionInterface + { + $this->tags[$tag] = true; + + return $this; + } + + /** + * @inheritDoc + */ + public function getTags(): array + { + return array_keys($this->tags); + } + + /** + * @inheritDoc + */ + public function hasTag(string $tag): bool + { + return isset($this->tags[$tag]); + } + + /** + * @inheritDoc + */ + public function setAlias(string $id): DefinitionInterface + { + $id = static::normaliseAlias($id); + + $this->alias = $id; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAlias(): string + { + return $this->alias; + } + + /** + * @inheritDoc + */ + public function setShared(bool $shared = true): DefinitionInterface + { + $this->shared = $shared; + + return $this; + } + + /** + * @inheritDoc + */ + public function isShared(): bool + { + return $this->shared; + } + + /** + * @inheritDoc + */ + public function getConcrete(): mixed + { + return $this->concrete; + } + + /** + * @inheritDoc + */ + public function setConcrete($concrete): DefinitionInterface + { + $this->concrete = $concrete; + $this->resolved = null; + + return $this; + } + + /** + * @inheritDoc + */ + public function addArgument($arg, ?string $name = null): DefinitionInterface + { + if ($name) { + $this->arguments[$name] = $arg; + } else { + $this->arguments[] = $arg; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function addArguments(array $args): DefinitionInterface + { + foreach ($args as $argName => $arg) { + if (is_string($argName)) { + $this->addArgument($arg, $argName); + } else { + $this->addArgument($arg); + } + } + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCall(string $method, array $args = []): DefinitionInterface + { + $this->methods[] = [ + 'method' => $method, + 'arguments' => $args, + ]; + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCalls(array $methods = []): DefinitionInterface + { + foreach ($methods as $method => $args) { + $this->addMethodCall($method, $args); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function resolve(): mixed + { + if ($this->resolved !== null && $this->isShared()) { + return $this->resolved; + } + + return $this->resolveNew(); + } + + /** + * @inheritDoc + */ + public function resolveNew(): mixed + { + $concrete = $this->concrete; + + if (is_callable($concrete)) { + $concrete = $this->resolveCallable($concrete); + } + + if ($concrete instanceof LiteralArgumentInterface) { + $this->resolved = $concrete->getValue(); + + return $concrete->getValue(); + } + + if ($concrete instanceof ArgumentInterface) { + $concrete = $concrete->getValue(); + } + + // Check if the container has a registered definition for this concrete class + // before attempting to instantiate it directly. This ensures interface -> concrete + // bindings respect existing definitions for the concrete class (fixes #275, #278). + try { + $container = $this->getContainer(); + } catch (ContainerException) { + $container = null; + } + + if ( + is_string($concrete) + && $concrete !== $this->alias + && $container !== null + && $container->hasDefinition($concrete) + ) { + $this->recursiveCheck[] = $concrete; + $concrete = $container->get($concrete); + $this->resolved = $concrete; + + return $concrete; + } + + if (is_string($concrete) && class_exists($concrete)) { + $concrete = $this->resolveClass($concrete); + } + + if (is_object($concrete)) { + $concrete = $this->invokeMethods($concrete); + } + + // stop recursive resolving + if (is_string($concrete) && in_array($concrete, $this->recursiveCheck)) { + $this->resolved = $concrete; + + return $concrete; + } + + // if we still have a string, try to pull it from the container + // this allows for `alias -> alias -> ... -> concrete + if (is_string($concrete) && $container !== null && $container->has($concrete)) { + $this->recursiveCheck[] = $concrete; + $concrete = $container->get($concrete); + } + + $this->resolved = $concrete; + + return $concrete; + } + + /** + * @param callable $concrete + * @return mixed + */ + protected function resolveCallable(callable $concrete): mixed + { + $resolved = $this->resolveArguments($this->arguments); + + return call_user_func_array($concrete, $resolved); + } + + /** + * @param class-string $concrete + * @return object + * @throws \ReflectionException + */ + protected function resolveClass(string $concrete): object + { + $resolved = $this->resolveArguments($this->arguments); + $reflection = new ReflectionClass($concrete); + + return $reflection->newInstanceArgs($resolved); + } + + /** + * @param object $instance + * @return object + */ + protected function invokeMethods(object $instance): object + { + foreach ($this->methods as $method) { + $args = $this->resolveArguments($method['arguments']); + /** @var callable $callable */ + $callable = [$instance, $method['method']]; + call_user_func_array($callable, $args); + } + + return $instance; + } + + /** + * @param string $alias + * @return string + */ + public static function normaliseAlias(string $alias): string + { + if (str_starts_with($alias, '\\')) { + return substr($alias, 1); + } + + return $alias; + } +} diff --git a/src/Container/Definition/DefinitionAggregate.php b/src/Container/Definition/DefinitionAggregate.php new file mode 100644 index 00000000000..a57f4ef1f5c --- /dev/null +++ b/src/Container/Definition/DefinitionAggregate.php @@ -0,0 +1,154 @@ + + */ + protected array $definitions = []; + + /** + * @param array $definitions + */ + public function __construct(array $definitions = []) + { + $this->definitions = array_filter($definitions, static function ($definition) { + return $definition instanceof DefinitionInterface; + }); + } + + /** + * @inheritDoc + */ + public function add(string $id, $definition): DefinitionInterface + { + if (!($definition instanceof DefinitionInterface)) { + $definition = new Definition($id, $definition); + } + + $this->definitions[] = $definition->setAlias($id); + + return $definition; + } + + /** + * @inheritDoc + */ + public function addShared(string $id, $definition): DefinitionInterface + { + $definition = $this->add($id, $definition); + + return $definition->setShared(true); + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + $id = Definition::normaliseAlias($id); + + foreach ($this->getIterator() as $definition) { + if ($id === $definition->getAlias()) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasTag(string $tag): bool + { + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function getDefinition(string $id): DefinitionInterface + { + $id = Definition::normaliseAlias($id); + + foreach ($this->getIterator() as $definition) { + if ($id === $definition->getAlias()) { + return $definition->setContainer($this->getContainer()); + } + } + + throw new NotFoundException(sprintf('Alias (%s) is not being handled as a definition.', $id)); + } + + /** + * @inheritDoc + */ + public function resolve(string $id): mixed + { + return $this->getDefinition($id)->resolve(); + } + + /** + * @inheritDoc + */ + public function resolveNew(string $id): mixed + { + return $this->getDefinition($id)->resolveNew(); + } + + /** + * @inheritDoc + */ + public function resolveTagged(string $tag): array + { + $arrayOf = []; + + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + $arrayOf[] = $definition->setContainer($this->getContainer())->resolve(); + } + } + + return $arrayOf; + } + + /** + * @inheritDoc + */ + public function resolveTaggedNew(string $tag): array + { + $arrayOf = []; + + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + $arrayOf[] = $definition->setContainer($this->getContainer())->resolveNew(); + } + } + + return $arrayOf; + } + + /** + * @inheritDoc + */ + public function getIterator(): Generator + { + yield from $this->definitions; + } +} diff --git a/src/Container/Definition/DefinitionAggregateInterface.php b/src/Container/Definition/DefinitionAggregateInterface.php new file mode 100644 index 00000000000..cb0dd079da0 --- /dev/null +++ b/src/Container/Definition/DefinitionAggregateInterface.php @@ -0,0 +1,69 @@ + + */ +interface DefinitionAggregateInterface extends ContainerAwareInterface, IteratorAggregate +{ + /** + * @param string $id + * @param mixed $definition + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function add(string $id, mixed $definition): DefinitionInterface; + + /** + * @param string $id + * @param mixed $definition + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function addShared(string $id, mixed $definition): DefinitionInterface; + + /** + * @param string $id + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function getDefinition(string $id): DefinitionInterface; + + /** + * @param string $id + * @return bool + */ + public function has(string $id): bool; + + /** + * @param string $tag + * @return bool + */ + public function hasTag(string $tag): bool; + + /** + * @param string $id + * @return mixed + */ + public function resolve(string $id): mixed; + + /** + * @param string $id + * @return mixed + */ + public function resolveNew(string $id): mixed; + + /** + * @param string $tag + * @return array + */ + public function resolveTagged(string $tag): array; + + /** + * @param string $tag + * @return array + */ + public function resolveTaggedNew(string $tag): array; +} diff --git a/src/Container/Definition/DefinitionInterface.php b/src/Container/Definition/DefinitionInterface.php new file mode 100644 index 00000000000..e3996d8b33c --- /dev/null +++ b/src/Container/Definition/DefinitionInterface.php @@ -0,0 +1,95 @@ + + */ + public function getTags(): array; + + /** + * @param string $tag + * @return bool + */ + public function hasTag(string $tag): bool; + + /** + * @return bool + */ + public function isShared(): bool; + + /** + * @return mixed + */ + public function resolve(): mixed; + + /** + * @return mixed + */ + public function resolveNew(): mixed; + + /** + * @param string $id + * @return $this + */ + public function setAlias(string $id): DefinitionInterface; + + /** + * @param mixed $concrete + * @return $this + */ + public function setConcrete(mixed $concrete): DefinitionInterface; + + /** + * @param bool $shared + * @return $this + */ + public function setShared(bool $shared): DefinitionInterface; +} diff --git a/src/Container/DefinitionContainerInterface.php b/src/Container/DefinitionContainerInterface.php new file mode 100644 index 00000000000..3600e9b76c4 --- /dev/null +++ b/src/Container/DefinitionContainerInterface.php @@ -0,0 +1,75 @@ + Foo::class]` - alias as key, class as value + * - `[Foo::class => [Bar::class]]` - class with constructor arguments + * + * @param array|class-string> $definitions + * @return self + */ + public function addDefinitions(array $definitions): self; + + /** + * @param \Cake\Container\ServiceProvider\ServiceProviderInterface $provider + * @return self + */ + public function addServiceProvider(ServiceProviderInterface $provider): self; + + /** + * @param string $id + * @param mixed $concrete + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function addShared(string $id, mixed $concrete = null): DefinitionInterface; + + /** + * @param string $id + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function extend(string $id): DefinitionInterface; + + /** + * @param mixed $id + * @return mixed + */ + public function getNew(mixed $id): mixed; + + /** + * Check if the container has a registered definition for the given id. + * + * Unlike `has()`, this only checks explicit definitions, not service providers + * or delegate containers. + * + * @param string $id + * @return bool + */ + public function hasDefinition(string $id): bool; + + /** + * @param string $type + * @param callable|null $callback + * @return \Cake\Container\Inflector\InflectorInterface + */ + public function inflector(string $type, ?callable $callback = null): InflectorInterface; +} diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php new file mode 100644 index 00000000000..3dc2c3200c5 --- /dev/null +++ b/src/Container/Exception/ContainerException.php @@ -0,0 +1,11 @@ +type = $type; + $this->callback = $callback; + } + + /** + * @inheritDoc + */ + public function getType(): string + { + return $this->type; + } + + /** + * @inheritDoc + */ + public function invokeMethod(string $name, array $args): InflectorInterface + { + $this->methods[$name] = $args; + + return $this; + } + + /** + * @inheritDoc + */ + public function invokeMethods(array $methods): InflectorInterface + { + foreach ($methods as $name => $args) { + $this->invokeMethod($name, $args); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperty(string $property, $value): InflectorInterface + { + $this->properties[$property] = $this->resolveArguments([$value])[0]; + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperties(array $properties): InflectorInterface + { + foreach ($properties as $property => $value) { + $this->setProperty($property, $value); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function inflect(object $object): void + { + $properties = $this->resolveArguments(array_values($this->properties)); + $properties = array_combine(array_keys($this->properties), $properties); + + // array_combine() can technically return false + foreach ($properties ?: [] as $property => $value) { + $object->{$property} = $value; + } + + foreach ($this->methods as $method => $args) { + $args = $this->resolveArguments($args); + /** @var callable $callable */ + $callable = [$object, $method]; + call_user_func_array($callable, $args); + } + + if ($this->callback !== null) { + call_user_func($this->callback, $object); + } + } +} diff --git a/src/Container/Inflector/InflectorAggregate.php b/src/Container/Inflector/InflectorAggregate.php new file mode 100644 index 00000000000..118973dd162 --- /dev/null +++ b/src/Container/Inflector/InflectorAggregate.php @@ -0,0 +1,53 @@ + + */ + protected array $inflectors = []; + + /** + * @inheritDoc + */ + public function add(string $type, ?callable $callback = null): Inflector + { + $inflector = new Inflector($type, $callback); + $this->inflectors[] = $inflector; + + return $inflector; + } + + /** + * @inheritDoc + */ + public function inflect($object): mixed + { + foreach ($this->getIterator() as $inflector) { + $type = $inflector->getType(); + + if ($object instanceof $type) { + $inflector->setContainer($this->getContainer()); + $inflector->inflect($object); + } + } + + return $object; + } + + /** + * @inheritDoc + */ + public function getIterator(): Generator + { + yield from $this->inflectors; + } +} diff --git a/src/Container/Inflector/InflectorAggregateInterface.php b/src/Container/Inflector/InflectorAggregateInterface.php new file mode 100644 index 00000000000..f80d97b641a --- /dev/null +++ b/src/Container/Inflector/InflectorAggregateInterface.php @@ -0,0 +1,26 @@ + + */ +interface InflectorAggregateInterface extends ContainerAwareInterface, IteratorAggregate +{ + /** + * @param string $type + * @param callable|null $callback + * @return \Cake\Container\Inflector\Inflector + */ + public function add(string $type, ?callable $callback = null): Inflector; + + /** + * @param object $object + * @return mixed + */ + public function inflect(object $object): mixed; +} diff --git a/src/Container/Inflector/InflectorInterface.php b/src/Container/Inflector/InflectorInterface.php new file mode 100644 index 00000000000..c954e72a64e --- /dev/null +++ b/src/Container/Inflector/InflectorInterface.php @@ -0,0 +1,44 @@ + + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/src/Container/README.md b/src/Container/README.md new file mode 100644 index 00000000000..bfa58fb91ea --- /dev/null +++ b/src/Container/README.md @@ -0,0 +1,19 @@ +# CakePHP Container Library + +TBD + +# Installation + +``` +composer require cakephp/container +``` + +# Getting Started + +TBD + +## Credits + +- [Phil Bennett](https://github.com/philipobenito) +- [thephpleague/container](https://github.com/thephpleague/container) +- `Orno\Di` contributors diff --git a/src/Container/ReflectionContainer.php b/src/Container/ReflectionContainer.php new file mode 100644 index 00000000000..864b621acd3 --- /dev/null +++ b/src/Container/ReflectionContainer.php @@ -0,0 +1,164 @@ +cacheResolutions = $cacheResolutions; + } + + /** + * @inheritDoc + */ + public function get(string $id, array $args = []) + { + // Only use cache when no custom args are provided + if ($this->cacheResolutions && $args === [] && array_key_exists($id, $this->cache)) { + return $this->cache[$id]; + } + + if (!$this->has($id)) { + throw new NotFoundException( + sprintf('Alias (%s) is not an existing class and therefore cannot be resolved', $id), + ); + } + + /** @var class-string $id */ + $reflector = new ReflectionClass($id); + $construct = $reflector->getConstructor(); + + if ($construct && !$construct->isPublic()) { + throw new NotFoundException( + sprintf('Alias (%s) has a non-public constructor and therefore cannot be instantiated', $id), + ); + } + + $resolution = $construct === null + ? new $id() + : $reflector->newInstanceArgs($this->reflectArguments($construct, $args)); + + // Only cache when no custom args are provided + if ($this->cacheResolutions && $args === []) { + $this->cache[$id] = $resolution; + } + + return $resolution; + } + + /** + * @inheritDoc + */ + public function has($id): bool + { + return class_exists($id); + } + + /** + * Get a new instance, bypassing the cache. + * + * @param string $id + * @param array $args + * @return mixed + */ + public function getNew(string $id, array $args = []): mixed + { + if (!$this->has($id)) { + throw new NotFoundException( + sprintf('Alias (%s) is not an existing class and therefore cannot be resolved', $id), + ); + } + + /** @var class-string $id */ + $reflector = new ReflectionClass($id); + $construct = $reflector->getConstructor(); + + if ($construct && !$construct->isPublic()) { + throw new NotFoundException( + sprintf('Alias (%s) has a non-public constructor and therefore cannot be instantiated', $id), + ); + } + + return $construct === null + ? new $id() + : $reflector->newInstanceArgs($this->reflectArguments($construct, $args)); + } + + /** + * @param callable|string $callable + * @param array $args + * @return mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \ReflectionException + */ + public function call(callable|string $callable, array $args = []): mixed + { + if (is_string($callable) && str_contains($callable, '::')) { + $callable = explode('::', $callable); + } + + if (is_array($callable)) { + if (is_string($callable[0])) { + // if we have a definition container, try that first, otherwise, reflect + try { + $callable[0] = $this->getContainer()->get($callable[0]); + } catch (ContainerException) { + $callable[0] = $this->get($callable[0]); + } + } + + $reflection = new ReflectionMethod($callable[0], $callable[1]); + + if ($reflection->isStatic()) { + $callable[0] = null; + } + + return $reflection->invokeArgs($callable[0], $this->reflectArguments($reflection, $args)); + } + + if (is_object($callable)) { + $reflection = new ReflectionMethod($callable, '__invoke'); + + return $reflection->invokeArgs($callable, $this->reflectArguments($reflection, $args)); + } + + if (is_callable($callable)) { + $reflection = new ReflectionFunction($callable(...)); + + return $reflection->invokeArgs($this->reflectArguments($reflection, $args)); + } + + throw new NotFoundException(sprintf( + 'Callable (%s) is not a valid callable', + $callable, + )); + } +} diff --git a/src/Container/ServiceProvider/AbstractServiceProvider.php b/src/Container/ServiceProvider/AbstractServiceProvider.php new file mode 100644 index 00000000000..3afb809fdb4 --- /dev/null +++ b/src/Container/ServiceProvider/AbstractServiceProvider.php @@ -0,0 +1,34 @@ +identifier ?? static::class; + } + + /** + * @inheritDoc + */ + public function setIdentifier(string $id): ServiceProviderInterface + { + $this->identifier = $id; + + return $this; + } +} diff --git a/src/Container/ServiceProvider/BootableServiceProviderInterface.php b/src/Container/ServiceProvider/BootableServiceProviderInterface.php new file mode 100644 index 00000000000..7c8e71936a1 --- /dev/null +++ b/src/Container/ServiceProvider/BootableServiceProviderInterface.php @@ -0,0 +1,15 @@ + + */ + protected array $providers = []; + + /** + * @var array + */ + protected array $registered = []; + + /** + * @inheritDoc + */ + public function add(ServiceProviderInterface $provider): ServiceProviderAggregateInterface + { + if (in_array($provider, $this->providers, true)) { + return $this; + } + + $provider->setContainer($this->getContainer()); + + if ($provider instanceof BootableServiceProviderInterface) { + $provider->boot(); + } + + $this->providers[] = $provider; + + return $this; + } + + /** + * @inheritDoc + */ + public function provides(string $id): bool + { + foreach ($this->getIterator() as $provider) { + if ($provider->provides($id)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function getIterator(): Generator + { + yield from $this->providers; + } + + /** + * @inheritDoc + */ + public function register(string $service): void + { + if ($this->provides($service) === false) { + throw new ContainerException( + sprintf('(%s) is not provided by a service provider', $service), + ); + } + + foreach ($this->getIterator() as $provider) { + if (in_array($provider->getIdentifier(), $this->registered, true)) { + continue; + } + + if ($provider->provides($service)) { + $provider->register(); + $this->registered[] = $provider->getIdentifier(); + } + } + } +} diff --git a/src/Container/ServiceProvider/ServiceProviderAggregateInterface.php b/src/Container/ServiceProvider/ServiceProviderAggregateInterface.php new file mode 100644 index 00000000000..d4bdcfb8adc --- /dev/null +++ b/src/Container/ServiceProvider/ServiceProviderAggregateInterface.php @@ -0,0 +1,31 @@ + + */ +interface ServiceProviderAggregateInterface extends ContainerAwareInterface, IteratorAggregate +{ + /** + * @param \Cake\Container\ServiceProvider\ServiceProviderInterface $provider + * @return $this + */ + public function add(ServiceProviderInterface $provider): ServiceProviderAggregateInterface; + + /** + * @param string $id + * @return bool + */ + public function provides(string $id): bool; + + /** + * @param string $service + * @return void + */ + public function register(string $service): void; +} diff --git a/src/Container/ServiceProvider/ServiceProviderInterface.php b/src/Container/ServiceProvider/ServiceProviderInterface.php new file mode 100644 index 00000000000..39dd8fe276f --- /dev/null +++ b/src/Container/ServiceProvider/ServiceProviderInterface.php @@ -0,0 +1,31 @@ +=8.2", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "autoload": { + "psr-4": { + "Cake\\Container\\": "." + } + } +} diff --git a/src/Controller/Attribute/Enum/RequestToDtoSource.php b/src/Controller/Attribute/Enum/RequestToDtoSource.php new file mode 100644 index 00000000000..4dc8293ec81 --- /dev/null +++ b/src/Controller/Attribute/Enum/RequestToDtoSource.php @@ -0,0 +1,15 @@ +class; + if ($dtoClass === null) { + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $dtoClass = $type->getName(); + } + } + + if ($dtoClass === null || !class_exists($dtoClass)) { + throw new InvalidParameterException([ + 'template' => 'missing_dependency', + 'parameter' => $parameter->getName(), + 'type' => $dtoClass ?? 'Dto', + ]); + } + + if (!method_exists($dtoClass, 'createFromArray')) { + throw new InvalidParameterException([ + 'template' => 'missing_dependency', + 'parameter' => $parameter->getName(), + 'type' => $dtoClass, + ]); + } + + /** @var class-string $dtoClass */ + return $dtoClass::createFromArray($this->extractData($request)); + } + + /** + * Extract data from request based on source. + * + * @param \Cake\Http\ServerRequest $request The server request + * @return array + */ + protected function extractData(ServerRequest $request): array + { + return match ($this->source) { + RequestToDtoSource::Body => (array)$request->getData(), + RequestToDtoSource::Query => $request->getQueryParams(), + RequestToDtoSource::Request => array_merge( + $request->getQueryParams(), + (array)$request->getData(), + ), + RequestToDtoSource::Auto => $this->extractAutoData($request), + }; + } + + /** + * Auto-detect data source based on request method. + * + * @param \Cake\Http\ServerRequest $request The server request + * @return array + */ + protected function extractAutoData(ServerRequest $request): array + { + if ($request->is(['get', 'head'])) { + return $request->getQueryParams(); + } + + $data = (array)$request->getData(); + if ($data !== []) { + return $data; + } + + return $request->getQueryParams(); + } +} diff --git a/src/Controller/Component/FormProtectionComponent.php b/src/Controller/Component/FormProtectionComponent.php index 924fbe87acf..f57d7ed7990 100644 --- a/src/Controller/Component/FormProtectionComponent.php +++ b/src/Controller/Component/FormProtectionComponent.php @@ -142,6 +142,33 @@ public function implementedEvents(): array ]; } + /** + * Unlock actions from validation. + * + * @param string|array $actions Action or list of actions to unlock. + * @param bool $merge Whether to merge with existing unlocked actions or replace them. + * @return $this + */ + public function unlockActions(string|array $actions, bool $merge = true) + { + return $this->setConfig('unlockedActions', (array)$actions, $merge); + } + + /** + * Unlock fields from validation. + * + * Dot notation can be used to unlock nested fields. For example, `user.name` + * will unlock the `name` field in the `user` array. + * + * @param string|array $fields Field or list of fields to unlock. + * @param bool $merge Whether to merge with existing unlocked fields or replace them. + * @return $this + */ + public function unlockFields(string|array $fields, bool $merge = true) + { + return $this->setConfig('unlockedFields', (array)$fields, $merge); + } + /** * Throws a 400 - Bad request exception or calls custom callback. * diff --git a/src/Controller/ComponentRegistry.php b/src/Controller/ComponentRegistry.php index 0e49f576ac8..db7e1a236c1 100644 --- a/src/Controller/ComponentRegistry.php +++ b/src/Controller/ComponentRegistry.php @@ -27,8 +27,8 @@ use League\Container\Argument\ArgumentResolverTrait; use League\Container\Argument\LiteralArgument; use League\Container\Argument\ResolvableArgument; -use League\Container\Exception\NotFoundException; use League\Container\ReflectionContainer; +use Psr\Container\NotFoundExceptionInterface; use ReflectionClass; use ReflectionFunctionAbstract; use ReflectionMethod; @@ -180,7 +180,7 @@ protected function _create(object|string $class, string $alias, array $config): try { $this->container->extend($class); $hasDefinition = true; - } catch (NotFoundException) { + } catch (NotFoundExceptionInterface) { // No definition exists yet } diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 1518729161c..8646c0d2a88 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -47,6 +47,7 @@ use ReflectionMethod; use function Cake\Core\namespaceSplit; use function Cake\Core\pluginSplit; +use function Cake\Core\triggerWarning; /** * Application controller class for organization of business logic. @@ -282,6 +283,28 @@ public function components(): ComponentRegistry */ public function loadComponent(string $name, array $config = []): Component { + [, $alias] = pluginSplit($name); + + if ($this->defaultTable) { + if (str_contains($this->defaultTable, '\\')) { + $tableAlias = App::shortName($this->defaultTable, 'Model/Table', 'Table'); + } else { + [, $tableAlias] = pluginSplit($this->defaultTable, true); + } + + if ($alias === $tableAlias) { + triggerWarning(sprintf( + 'Component alias `%s` clashes with the default table name `%s`. ' . + 'The table name will take precedence when accessing `$this->%s`. ' . + 'Consider using a different component alias or set `Controller::$defaultTable` to ' . + "an empty string if the controller doesn't use a table.", + $alias, + $this->defaultTable, + $alias, + )); + } + } + return $this->components()->load($name, $config); } diff --git a/src/Controller/ControllerFactory.php b/src/Controller/ControllerFactory.php index 486f25112e1..9b4079287ce 100644 --- a/src/Controller/ControllerFactory.php +++ b/src/Controller/ControllerFactory.php @@ -16,6 +16,7 @@ */ namespace Cake\Controller; +use Cake\Controller\Attribute\ParameterAttributeInterface; use Cake\Controller\Exception\InvalidParameterException; use Cake\Core\App; use Cake\Core\ContainerInterface; @@ -31,6 +32,7 @@ use ReflectionClass; use ReflectionFunction; use ReflectionNamedType; +use ReflectionParameter; use function Cake\Core\toBool; use function Cake\Core\toFloat; use function Cake\Core\toInt; @@ -184,7 +186,14 @@ protected function getActionArgs(Closure $action, array $passedParams): array { $resolved = []; $function = new ReflectionFunction($action); + $request = $this->controller->getRequest(); foreach ($function->getParameters() as $parameter) { + $attributeValue = $this->resolveParameterAttribute($parameter, $request); + if ($attributeValue !== null) { + $resolved[] = $attributeValue; + continue; + } + $type = $parameter->getType(); // Check for dependency injection for classes @@ -269,6 +278,25 @@ protected function getActionArgs(Closure $action, array $passedParams): array return array_merge($resolved, $passedParams); } + /** + * Resolve parameter value from attributes implementing ParameterAttributeInterface. + * + * @param \ReflectionParameter $parameter The parameter to resolve + * @param \Cake\Http\ServerRequest $request The server request + * @return mixed The resolved value or null if no matching attribute found + */ + protected function resolveParameterAttribute(ReflectionParameter $parameter, ServerRequest $request): mixed + { + foreach ($parameter->getAttributes() as $attribute) { + $instance = $attribute->newInstance(); + if ($instance instanceof ParameterAttributeInterface) { + return $instance->resolve($parameter, $request); + } + } + + return null; + } + /** * Coerces string argument to primitive type. * diff --git a/src/Core/Attribute/Configure.php b/src/Core/Attribute/Configure.php index 92d9e4e7d21..4bd35b9a205 100644 --- a/src/Core/Attribute/Configure.php +++ b/src/Core/Attribute/Configure.php @@ -42,12 +42,12 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER)] -class Configure implements AttributeInterface +readonly class Configure implements AttributeInterface { /** * @param string $name */ - public function __construct(private string $name) + public function __construct(protected string $name) { } diff --git a/src/Core/CakeContainerBridge.php b/src/Core/CakeContainerBridge.php new file mode 100644 index 00000000000..ec2e9acae7a --- /dev/null +++ b/src/Core/CakeContainerBridge.php @@ -0,0 +1,132 @@ +container->add($id, $concrete); + + return new CakeDefinitionBridge($definition, $this); + } + + /** + * @inheritDoc + */ + public function addServiceProvider(ServiceProviderInterface $provider): static + { + if (!$provider instanceof CakeServiceProviderInterface) { + throw new InvalidArgumentException(sprintf( + 'Service provider must implement `%s` when using the CakePHP container', + CakeServiceProviderInterface::class, + )); + } + $this->container->addServiceProvider($provider); + + return $this; + } + + /** + * @inheritDoc + */ + public function addShared(string $id, mixed $concrete = null, bool $overwrite = false): DefinitionInterface + { + $definition = $this->container->addShared($id, $concrete); + + return new CakeDefinitionBridge($definition, $this); + } + + /** + * @inheritDoc + */ + public function extend(string $id): DefinitionInterface + { + $definition = $this->container->extend($id); + + return new CakeDefinitionBridge($definition, $this); + } + + /** + * @inheritDoc + */ + public function getNew(string $id): mixed + { + return $this->container->getNew($id); + } + + /** + * @inheritDoc + */ + public function inflector(string $type, ?callable $callback = null): InflectorInterface + { + $inflector = $this->container->inflector($type, $callback); + + return new CakeInflectorBridge($inflector); + } + + /** + * @inheritDoc + */ + public function get(string $id): mixed + { + return $this->container->get($id); + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + return $this->container->has($id); + } + + /** + * @inheritDoc + */ + public function delegate(PsrContainerInterface $container): PsrContainerInterface + { + $this->container->delegate($container); + + return $this; + } +} diff --git a/src/Core/CakeDefinitionBridge.php b/src/Core/CakeDefinitionBridge.php new file mode 100644 index 00000000000..24c4aa210b0 --- /dev/null +++ b/src/Core/CakeDefinitionBridge.php @@ -0,0 +1,195 @@ +definition->addArgument($arg); + + return $this; + } + + /** + * @inheritDoc + */ + public function addArguments(array $args): DefinitionInterface + { + $this->definition->addArguments($args); + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCall(string $method, array $args = []): DefinitionInterface + { + $this->definition->addMethodCall($method, $args); + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCalls(array $methods = []): DefinitionInterface + { + $this->definition->addMethodCalls($methods); + + return $this; + } + + /** + * @inheritDoc + */ + public function addTag(string $tag): DefinitionInterface + { + $this->definition->addTag($tag); + + return $this; + } + + /** + * @inheritDoc + */ + public function getAlias(): string + { + return $this->definition->getAlias(); + } + + /** + * @inheritDoc + */ + public function getConcrete(): mixed + { + return $this->definition->getConcrete(); + } + + /** + * @inheritDoc + */ + public function getTags(): array + { + return $this->definition->getTags(); + } + + /** + * @inheritDoc + */ + public function hasTag(string $tag): bool + { + return $this->definition->hasTag($tag); + } + + /** + * @inheritDoc + */ + public function isShared(): bool + { + return $this->definition->isShared(); + } + + /** + * @inheritDoc + */ + public function resolve(): mixed + { + return $this->definition->resolve(); + } + + /** + * @inheritDoc + */ + public function resolveNew(): mixed + { + return $this->definition->resolveNew(); + } + + /** + * @inheritDoc + */ + public function setAlias(string $id): DefinitionInterface + { + // CakePHP's Definition doesn't have setAlias, but we can return self + return $this; + } + + /** + * @inheritDoc + */ + public function setConcrete(mixed $concrete): DefinitionInterface + { + $this->definition->setConcrete($concrete); + + return $this; + } + + /** + * @inheritDoc + */ + public function setShared(bool $shared): DefinitionInterface + { + $this->definition->setShared($shared); + + return $this; + } + + /** + * @inheritDoc + */ + public function getContainer(): DefinitionContainerInterface + { + return $this->container; + } + + /** + * @inheritDoc + */ + public function setContainer(DefinitionContainerInterface $container): ContainerAwareInterface + { + $this->container = $container; + + return $this; + } +} diff --git a/src/Core/CakeInflectorBridge.php b/src/Core/CakeInflectorBridge.php new file mode 100644 index 00000000000..3b3889a45f7 --- /dev/null +++ b/src/Core/CakeInflectorBridge.php @@ -0,0 +1,94 @@ +inflector->getType(); + } + + /** + * @inheritDoc + */ + public function invokeMethod(string $name, array $args): InflectorInterface + { + $this->inflector->invokeMethod($name, $args); + + return $this; + } + + /** + * @inheritDoc + */ + public function invokeMethods(array $methods): InflectorInterface + { + $this->inflector->invokeMethods($methods); + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperty(string $property, mixed $value): InflectorInterface + { + $this->inflector->setProperty($property, $value); + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperties(array $properties): InflectorInterface + { + $this->inflector->setProperties($properties); + + return $this; + } + + /** + * @inheritDoc + */ + public function inflect(object $object): void + { + $this->inflector->inflect($object); + } +} diff --git a/src/Core/ConsoleHelpHeaderProviderInterface.php b/src/Core/ConsoleHelpHeaderProviderInterface.php new file mode 100644 index 00000000000..176a169825d --- /dev/null +++ b/src/Core/ConsoleHelpHeaderProviderInterface.php @@ -0,0 +1,29 @@ + new CakeContainerBridge(new CakeContainer()), + default => new Container(), + }; + } +} diff --git a/src/Core/PluginConfig.php b/src/Core/PluginConfig.php index 5d02c0a82dc..6cfe257928f 100644 --- a/src/Core/PluginConfig.php +++ b/src/Core/PluginConfig.php @@ -26,6 +26,13 @@ */ class PluginConfig { + /** + * Cache for installed plugins to avoid re-reading files + * + * @var array>|null + */ + private static ?array $cachedPlugins = null; + /** * Load the path information stored in vendor/cakephp-plugins.php * @@ -56,13 +63,30 @@ public static function loadInstallerConfig(): void } /** - * Get the config how plugins should be loaded + * Get an array of all installed plugins and their configuration options. * - * @param string|null $path The absolute path to the composer.lock file to retrieve the versions from - * @return array + * Returns an array of plugin configurations with keys: + * - bootstrap: Enable bootstrap hook (if isLoaded) + * - console: Enable console hook (if isLoaded) + * - events: Enable events hook (if isLoaded) + * - isLoaded: Whether plugin is configured to load + * - isUnknown: Present and set to true when a plugin is configured but not found in the installed plugins list + * - middleware: Enable middleware hook (if isLoaded) + * - onlyCli: Load only in CLI mode (if isLoaded) + * - onlyDebug: Load only in debug mode (if isLoaded) + * - optional: Plugin is optional (if isLoaded) + * - path: Plugin filesystem path (only present for installed plugins, not for unknown ones) + * - routes: Enable routes hook (if isLoaded) + * - services: Enable services hook (if isLoaded) + * + * @return array> Plugin name => configuration */ - public static function getAppConfig(?string $path = null): array + public static function getInstalledPlugins(): array { + if (self::$cachedPlugins !== null) { + return self::$cachedPlugins; + } + self::loadInstallerConfig(); // phpcs:ignore @@ -73,12 +97,6 @@ public static function getAppConfig(?string $path = null): array $pluginLoadConfig = []; } - try { - $composerVersions = self::getVersions($path); - } catch (CakeException) { - $composerVersions = []; - } - $result = []; $availablePlugins = Configure::read('plugins', []); if ($availablePlugins && is_array($availablePlugins)) { @@ -87,6 +105,7 @@ public static function getAppConfig(?string $path = null): array $options = $pluginLoadConfig[$pluginName]; $hooks = PluginInterface::VALID_HOOKS; $mainConfig = [ + 'path' => $pluginPath, 'isLoaded' => true, 'onlyDebug' => $options['onlyDebug'] ?? false, 'onlyCli' => $options['onlyCli'] ?? false, @@ -97,24 +116,10 @@ public static function getAppConfig(?string $path = null): array } $result[$pluginName] = $mainConfig; } else { - $result[$pluginName]['isLoaded'] = false; - } - - try { - $packageName = self::getPackageNameFromPath($pluginPath); - $result[$pluginName]['packagePath'] = $pluginPath; - $result[$pluginName]['package'] = $packageName; - } catch (CakeException) { - $packageName = null; - } - if ($composerVersions && $packageName) { - if (array_key_exists($packageName, $composerVersions['packages'])) { - $result[$pluginName]['version'] = $composerVersions['packages'][$packageName]; - $result[$pluginName]['isDevPackage'] = false; - } elseif (array_key_exists($packageName, $composerVersions['devPackages'])) { - $result[$pluginName]['version'] = $composerVersions['devPackages'][$packageName]; - $result[$pluginName]['isDevPackage'] = true; - } + $result[$pluginName] = [ + 'path' => $pluginPath, + 'isLoaded' => false, + ]; } } } @@ -125,12 +130,74 @@ public static function getAppConfig(?string $path = null): array $result[$unknownPlugin]['isUnknown'] = true; } + return self::$cachedPlugins = $result; + } + + /** + * Clear the cached plugins data. Useful for testing. + * + * @return void + */ + public static function clearCache(): void + { + self::$cachedPlugins = null; + } + + /** + * Get the config how plugins should be loaded with enriched package metadata. + * + * @param string|null $path The absolute path to the composer.lock file to retrieve the versions from + * @return array> Plugin name => enriched configuration with package metadata + */ + public static function getAppConfig(?string $path = null): array + { + // Get base plugin configuration (paths and load config) + $result = self::getInstalledPlugins(); + + try { + $composerVersions = self::getVersions($path); + } catch (CakeException) { + $composerVersions = []; + } + + // Enrich with package metadata and versions + foreach ($result as $pluginName => $config) { + // Skip unknown plugins (no path available) + if (!isset($config['path'])) { + continue; + } + + try { + $packageName = self::getPackageNameFromPath($config['path']); + $result[$pluginName]['packagePath'] = $config['path']; + $result[$pluginName]['package'] = $packageName; + } catch (CakeException) { + $packageName = null; + } + + if ($composerVersions && $packageName) { + foreach (['packages' => false, 'devPackages' => true] as $key => $isDev) { + if (array_key_exists($packageName, $composerVersions[$key])) { + $result[$pluginName]['version'] = $composerVersions[$key][$packageName]; + $result[$pluginName]['isDevPackage'] = $isDev; + break; + } + } + } + + // Remove 'path' key to maintain BC (getAppConfig uses packagePath instead) + unset($result[$pluginName]['path']); + } + return $result; } /** + * Get package versions from composer.lock file. + * * @param string|null $path The absolute path to the composer.lock file to retrieve the versions from - * @return array + * @return array{packages: array, devPackages: array} Array with 'packages' and 'devPackages' keys + * @throws \Cake\Core\Exception\CakeException When composer.lock is missing, unreadable, or invalid */ public static function getVersions(?string $path = null): array { @@ -160,8 +227,11 @@ public static function getVersions(?string $path = null): array } /** - * @param string $path - * @return string + * Extract package name from composer.json in the given path. + * + * @param string $path The plugin path containing composer.json + * @return string The package name (e.g., 'cakephp/debug-kit') + * @throws \Cake\Core\Exception\CakeException When composer.json is missing, unreadable, or invalid */ protected static function getPackageNameFromPath(string $path): string { diff --git a/src/Core/TestSuite/ContainerStubTrait.php b/src/Core/TestSuite/ContainerStubTrait.php index b58f7a19b51..5b05ea24efb 100644 --- a/src/Core/TestSuite/ContainerStubTrait.php +++ b/src/Core/TestSuite/ContainerStubTrait.php @@ -24,9 +24,9 @@ use Cake\Event\EventInterface; use Cake\Routing\Router; use Closure; -use League\Container\Exception\NotFoundException; use LogicException; use PHPUnit\Framework\Attributes\After; +use Psr\Container\NotFoundExceptionInterface; /** * A set of methods used for defining container services @@ -165,7 +165,7 @@ public function modifyContainer(EventInterface $event, ContainerInterface $conta if ($container->has($key)) { try { $container->extend($key)->setConcrete($factory); - } catch (NotFoundException) { + } catch (NotFoundExceptionInterface) { $container->add($key, $factory); } } else { diff --git a/src/Core/composer.json b/src/Core/composer.json index 68607d314d9..518b5618769 100644 --- a/src/Core/composer.json +++ b/src/Core/composer.json @@ -23,7 +23,7 @@ }, "require": { "php": ">=8.2", - "cakephp/utility": "^5.3.0", + "cakephp/utility": "5.4.*@dev", "league/container": "^5.1", "psr/container": "^1.1 || ^2.0" }, diff --git a/src/Database/Connection.php b/src/Database/Connection.php index 119dfb9b66f..8f859d889b9 100644 --- a/src/Database/Connection.php +++ b/src/Database/Connection.php @@ -105,6 +105,13 @@ class Connection implements ConnectionInterface */ protected ?NestedTransactionRollbackException $nestedTransactionRollbackException = null; + /** + * Callbacks to execute after the outermost transaction commits. + * + * @var array<\Closure> + */ + protected array $afterCommitCallbacks = []; + protected QueryFactory $queryFactory; /** @@ -471,6 +478,26 @@ public function begin(): void } } + /** + * Register a callback to run after the outermost transaction commits. + * + * If no transaction is active, the callback executes immediately. + * Callbacks are discarded on rollback. + * + * @param \Closure $callback Callback to execute after commit. + * @return void + */ + public function afterCommit(Closure $callback): void + { + if (!$this->_transactionStarted) { + $callback(); + + return; + } + + $this->afterCommitCallbacks[] = $callback; + } + /** * Commits current transaction. * @@ -494,7 +521,15 @@ public function commit(): bool $this->_transactionStarted = false; $this->nestedTransactionRollbackException = null; - return $this->getWriteDriver()->commitTransaction(); + $result = $this->getWriteDriver()->commitTransaction(); + + $callbacks = $this->afterCommitCallbacks; + $this->afterCommitCallbacks = []; + foreach ($callbacks as $cb) { + $cb(); + } + + return $result; } if ($this->isSavePointsEnabled()) { $this->releaseSavePoint((string)$this->_transactionLevel); @@ -524,6 +559,7 @@ public function rollback(?bool $toBeginning = null): bool $this->_transactionLevel = 0; $this->_transactionStarted = false; $this->nestedTransactionRollbackException = null; + $this->afterCommitCallbacks = []; $this->getWriteDriver()->rollbackTransaction(); return true; diff --git a/src/Database/Driver/Mysql.php b/src/Database/Driver/Mysql.php index a43e478e8ad..6c3af56cbff 100644 --- a/src/Database/Driver/Mysql.php +++ b/src/Database/Driver/Mysql.php @@ -18,6 +18,8 @@ use Cake\Database\Driver; use Cake\Database\DriverFeatureEnum; +use Cake\Database\Expression\DistinctComparisonExpression; +use Cake\Database\Expression\StringAggExpression; use Cake\Database\Query; use Cake\Database\Query\SelectQuery; use Cake\Database\Schema\MysqlSchemaDialect; @@ -31,6 +33,55 @@ */ class Mysql extends Driver { + /** + * @inheritDoc + */ + protected function _expressionTranslators(): array + { + return [ + StringAggExpression::class => 'transformStringAggExpression', + DistinctComparisonExpression::class => 'transformDistinctComparisonExpression', + ]; + } + + /** + * Translates IS [NOT] DISTINCT FROM into MySQL-specific syntax. + * + * @param \Cake\Database\Expression\DistinctComparisonExpression $expression The expression to translate. + * @return void + */ + protected function transformDistinctComparisonExpression(DistinctComparisonExpression $expression): void + { + $operator = strtoupper($expression->getOperator()); + if ($operator === 'IS NOT DISTINCT FROM') { + $expression->setOperator('<=>'); + } elseif ($operator === 'IS DISTINCT FROM') { + $expression->setOperator('<=>'); + $expression->setNot(true); + } + } + + /** + * Translates portable string aggregation to MySQL/MariaDB specific syntax. + * + * @param \Cake\Database\Expression\StringAggExpression $expression The expression to translate. + * @return void + */ + protected function transformStringAggExpression(StringAggExpression $expression): void + { + if ($this->supports(DriverFeatureEnum::STRING_AGG)) { + $expression + ->setName('STRING_AGG') + ->setSyntax(StringAggExpression::SYNTAX_STANDARD); + + return; + } + + $expression + ->setName('GROUP_CONCAT') + ->setSyntax(StringAggExpression::SYNTAX_GROUP_CONCAT); + } + /** * @inheritDoc */ @@ -102,16 +153,22 @@ class Mysql extends Driver 'json' => '5.7.0', 'cte' => '8.0.0', 'window' => '8.0.0', + 'string-agg' => '99.0.0', 'intersect' => '8.0.31', 'intersect-all' => '8.0.31', + 'except' => '8.0.31', + 'except-all' => '8.0.31', 'check-constraints' => '8.0.16', ], 'mariadb' => [ 'json' => '10.2.7', 'cte' => '10.2.1', 'window' => '10.2.0', + 'string-agg' => '10.5.0', 'intersect' => '10.3.0', 'intersect-all' => '10.5.0', + 'except' => '10.3.0', + 'except-all' => '10.5.0', 'check-constraints' => '10.2.1', ], ]; @@ -255,8 +312,12 @@ public function supports(DriverFeatureEnum $feature): bool DriverFeatureEnum::CTE, DriverFeatureEnum::JSON, DriverFeatureEnum::WINDOW => $versionCompare(), + DriverFeatureEnum::STRING_AGG => $versionCompare(), + DriverFeatureEnum::GROUP_CONCAT => true, DriverFeatureEnum::INTERSECT => $versionCompare(), DriverFeatureEnum::INTERSECT_ALL => $versionCompare(), + DriverFeatureEnum::EXCEPT => $versionCompare(), + DriverFeatureEnum::EXCEPT_ALL => $versionCompare(), DriverFeatureEnum::CHECK_CONSTRAINTS => $versionCompare(), DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => true, DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => true, diff --git a/src/Database/Driver/Postgres.php b/src/Database/Driver/Postgres.php index 78574daea08..3795733a1d8 100644 --- a/src/Database/Driver/Postgres.php +++ b/src/Database/Driver/Postgres.php @@ -20,6 +20,7 @@ use Cake\Database\DriverFeatureEnum; use Cake\Database\Expression\FunctionExpression; use Cake\Database\Expression\IdentifierExpression; +use Cake\Database\Expression\StringAggExpression; use Cake\Database\Expression\StringExpression; use Cake\Database\PostgresCompiler; use Cake\Database\Query\InsertQuery; @@ -206,8 +207,12 @@ public function supports(DriverFeatureEnum $feature): bool DriverFeatureEnum::SAVEPOINT, DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS, DriverFeatureEnum::WINDOW => true, + DriverFeatureEnum::STRING_AGG => true, + DriverFeatureEnum::GROUP_CONCAT => false, DriverFeatureEnum::INTERSECT => true, DriverFeatureEnum::INTERSECT_ALL => true, + DriverFeatureEnum::EXCEPT => true, + DriverFeatureEnum::EXCEPT_ALL => true, DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => true, DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION => false, DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => true, @@ -242,11 +247,26 @@ protected function _expressionTranslators(): array { return [ IdentifierExpression::class => '_transformIdentifierExpression', + StringAggExpression::class => '_transformStringAggExpression', FunctionExpression::class => '_transformFunctionExpression', StringExpression::class => '_transformStringExpression', ]; } + /** + * Receives a StringAggExpression and changes it so that it conforms to this + * SQL dialect. + * + * @param \Cake\Database\Expression\StringAggExpression $expression The expression to convert. + * @return void + */ + protected function _transformStringAggExpression(StringAggExpression $expression): void + { + $expression + ->setName('STRING_AGG') + ->setSyntax(StringAggExpression::SYNTAX_STANDARD); + } + /** * Changes identifier expression into postgresql format. * diff --git a/src/Database/Driver/Sqlite.php b/src/Database/Driver/Sqlite.php index 2635dfb139f..56ed75f129e 100644 --- a/src/Database/Driver/Sqlite.php +++ b/src/Database/Driver/Sqlite.php @@ -19,6 +19,7 @@ use Cake\Database\Driver; use Cake\Database\DriverFeatureEnum; use Cake\Database\Expression\FunctionExpression; +use Cake\Database\Expression\StringAggExpression; use Cake\Database\Expression\TupleComparison; use Cake\Database\Schema\SchemaDialect; use Cake\Database\Schema\SqliteSchemaDialect; @@ -101,6 +102,7 @@ class Sqlite extends Driver */ protected array $featureVersions = [ 'cte' => '3.8.3', + 'string-agg' => '3.44.0', 'window' => '3.28.0', ]; @@ -199,13 +201,17 @@ public function supports(DriverFeatureEnum $feature): bool DriverFeatureEnum::JSON => false, DriverFeatureEnum::CTE, + DriverFeatureEnum::STRING_AGG, DriverFeatureEnum::WINDOW => version_compare( $this->version(), $this->featureVersions[$feature->value], '>=', ), + DriverFeatureEnum::GROUP_CONCAT => true, DriverFeatureEnum::INTERSECT => true, DriverFeatureEnum::INTERSECT_ALL => false, + DriverFeatureEnum::EXCEPT => true, + DriverFeatureEnum::EXCEPT_ALL => false, DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => false, DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => false, DriverFeatureEnum::CHECK_CONSTRAINTS => true, @@ -226,11 +232,26 @@ public function schemaDialect(): SchemaDialect protected function _expressionTranslators(): array { return [ + StringAggExpression::class => '_transformStringAggExpression', FunctionExpression::class => '_transformFunctionExpression', TupleComparison::class => '_transformTupleComparison', ]; } + /** + * Receives a StringAggExpression and changes it so that it conforms to this + * SQL dialect. + * + * @param \Cake\Database\Expression\StringAggExpression $expression The expression to convert. + * @return void + */ + protected function _transformStringAggExpression(StringAggExpression $expression): void + { + $expression + ->setName($this->supports(DriverFeatureEnum::STRING_AGG) ? 'STRING_AGG' : 'GROUP_CONCAT') + ->setSyntax(StringAggExpression::SYNTAX_STANDARD); + } + /** * Receives a FunctionExpression and changes it so that it conforms to this * SQL dialect. diff --git a/src/Database/Driver/Sqlserver.php b/src/Database/Driver/Sqlserver.php index b9d0fb77f01..aa8c48f4b98 100644 --- a/src/Database/Driver/Sqlserver.php +++ b/src/Database/Driver/Sqlserver.php @@ -21,6 +21,7 @@ use Cake\Database\Expression\FunctionExpression; use Cake\Database\Expression\OrderByExpression; use Cake\Database\Expression\OrderClauseExpression; +use Cake\Database\Expression\StringAggExpression; use Cake\Database\Expression\TupleComparison; use Cake\Database\Expression\UnaryExpression; use Cake\Database\ExpressionInterface; @@ -284,8 +285,12 @@ public function supports(DriverFeatureEnum $feature): bool DriverFeatureEnum::SAVEPOINT, DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS, DriverFeatureEnum::WINDOW => true, + DriverFeatureEnum::STRING_AGG => version_compare($this->version(), '14', '>='), + DriverFeatureEnum::GROUP_CONCAT => false, DriverFeatureEnum::INTERSECT => true, DriverFeatureEnum::INTERSECT_ALL => false, + DriverFeatureEnum::EXCEPT => true, + DriverFeatureEnum::EXCEPT_ALL => false, DriverFeatureEnum::JSON => false, DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => false, DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => false, @@ -466,11 +471,26 @@ protected function _transformDistinct(SelectQuery $query): SelectQuery protected function _expressionTranslators(): array { return [ + StringAggExpression::class => '_transformStringAggExpression', FunctionExpression::class => '_transformFunctionExpression', TupleComparison::class => '_transformTupleComparison', ]; } + /** + * Receives a StringAggExpression and changes it so that it conforms to this + * SQL dialect. + * + * @param \Cake\Database\Expression\StringAggExpression $expression The expression to convert to TSQL. + * @return void + */ + protected function _transformStringAggExpression(StringAggExpression $expression): void + { + $expression + ->setName('STRING_AGG') + ->setSyntax(StringAggExpression::SYNTAX_WITHIN_GROUP); + } + /** * Receives a FunctionExpression and changes it so that it conforms to this * SQL dialect. diff --git a/src/Database/DriverFeatureEnum.php b/src/Database/DriverFeatureEnum.php index 15219158c10..e329e6e4bac 100644 --- a/src/Database/DriverFeatureEnum.php +++ b/src/Database/DriverFeatureEnum.php @@ -59,7 +59,17 @@ enum DriverFeatureEnum: string case INTERSECT_ALL = 'intersect-all'; /** - * Support for order by in set operations (union, intersect) + * Except feature support + */ + case EXCEPT = 'except'; + + /** + * Except all feature support + */ + case EXCEPT_ALL = 'except-all'; + + /** + * Support for order by in set operations (union, intersect, except) */ case SET_OPERATIONS_ORDER_BY = 'set-operations-order-by'; @@ -72,4 +82,14 @@ enum DriverFeatureEnum: string * Support for CHECK constraints. */ case CHECK_CONSTRAINTS = 'check-constraints'; + + /** + * String aggregation via STRING_AGG support. + */ + case STRING_AGG = 'string-agg'; + + /** + * String aggregation via GROUP_CONCAT support. + */ + case GROUP_CONCAT = 'group-concat'; } diff --git a/src/Database/Expression/BetweenExpression.php b/src/Database/Expression/BetweenExpression.php index 253fc12be41..db66959c835 100644 --- a/src/Database/Expression/BetweenExpression.php +++ b/src/Database/Expression/BetweenExpression.php @@ -50,6 +50,13 @@ class BetweenExpression implements ExpressionInterface, FieldInterface */ protected mixed $_type; + /** + * Whether this is a NOT BETWEEN expression + * + * @var bool + */ + protected bool $_not = false; + /** * Constructor * @@ -57,9 +64,15 @@ class BetweenExpression implements ExpressionInterface, FieldInterface * @param mixed $from The initial value of the range. * @param mixed $to The ending value in the comparison range. * @param string|null $type The data type name to bind the values with. + * @param bool $not Whether this is a NOT BETWEEN expression. */ - public function __construct(ExpressionInterface|string $field, mixed $from, mixed $to, ?string $type = null) - { + public function __construct( + ExpressionInterface|string $field, + mixed $from, + mixed $to, + ?string $type = null, + bool $not = false, + ) { if ($type !== null) { $from = $this->_castToExpression($from, $type); $to = $this->_castToExpression($to, $type); @@ -69,6 +82,7 @@ public function __construct(ExpressionInterface|string $field, mixed $from, mixe $this->_from = $from; $this->_to = $to; $this->_type = $type; + $this->_not = $not; } /** @@ -95,7 +109,9 @@ public function sql(ValueBinder $binder): string } assert(is_string($field)); - return sprintf('%s BETWEEN %s AND %s', $field, $parts['from'], $parts['to']); + $operator = $this->_not ? 'NOT BETWEEN' : 'BETWEEN'; + + return sprintf('%s %s %s AND %s', $field, $operator, $parts['from'], $parts['to']); } /** diff --git a/src/Database/Expression/DistinctComparisonExpression.php b/src/Database/Expression/DistinctComparisonExpression.php new file mode 100644 index 00000000000..541d668cf4c --- /dev/null +++ b/src/Database/Expression/DistinctComparisonExpression.php @@ -0,0 +1,74 @@ +isNot = $not; + + return $this; + } + + /** + * @inheritDoc + */ + public function sql(ValueBinder $binder): string + { + $field = $this->_field; + + if ($field instanceof ExpressionInterface) { + $field = $field->sql($binder); + } + + if ($this->_value instanceof IdentifierExpression) { + $template = '%s %s %s'; + $value = $this->_value->sql($binder); + } elseif ($this->_value instanceof ExpressionInterface) { + $template = '%s %s (%s)'; + $value = $this->_value->sql($binder); + } else { + [$template, $value] = $this->_stringExpression($binder); + } + + /** @var string $field */ + $sql = sprintf($template, $field, $this->_operator, $value); + + return $this->isNot ? "NOT ({$sql})" : $sql; + } +} diff --git a/src/Database/Expression/QueryExpression.php b/src/Database/Expression/QueryExpression.php index 1140e623bfc..c5cdeb1f294 100644 --- a/src/Database/Expression/QueryExpression.php +++ b/src/Database/Expression/QueryExpression.php @@ -259,6 +259,36 @@ public function isNotNull(ExpressionInterface|string $field) return $this->add(new UnaryExpression('IS NOT NULL', $field, UnaryExpression::POSTFIX)); } + /** + * Adds a new condition to the expression object in the form "field IS DISTINCT FROM value". + * + * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value + * @param mixed $value The value to be bound to $field for comparison + * @param string|null $type the type name for $value as configured using the Type map. + * @return $this + */ + public function isDistinctFrom(ExpressionInterface|string $field, mixed $value, ?string $type = null) + { + $type ??= $this->_calculateType($field); + + return $this->add(new DistinctComparisonExpression($field, $value, $type, 'IS DISTINCT FROM')); + } + + /** + * Adds a new condition to the expression object in the form "field IS NOT DISTINCT FROM value". + * + * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value + * @param mixed $value The value to be bound to $field for comparison + * @param string|null $type the type name for $value as configured using the Type map. + * @return $this + */ + public function isNotDistinctFrom(ExpressionInterface|string $field, mixed $value, ?string $type = null) + { + $type ??= $this->_calculateType($field); + + return $this->add(new DistinctComparisonExpression($field, $value, $type, 'IS NOT DISTINCT FROM')); + } + /** * Adds a new condition to the expression object in the form "field LIKE value". * @@ -365,6 +395,28 @@ public function notIn( return $this->add(new ComparisonExpression($field, $values, $type, 'NOT IN')); } + /** + * Adds a new condition to the expression object in the form + * "(field IN (value1, value2) OR field IS NULL". + * + * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value + * @param \Cake\Database\ExpressionInterface|array|string $values the value to be bound to $field for comparison + * @param string|null $type the type name for $value as configured using the Type map. + * @return $this + */ + public function inOrNull( + ExpressionInterface|string $field, + ExpressionInterface|array|string $values, + ?string $type = null, + ) { + $or = new static([], $this->getTypeMap(), 'OR'); + $or + ->in($field, $values, $type) + ->isNull($field); + + return $this->add($or); + } + /** * Adds a new condition to the expression object in the form * "(field NOT IN (value1, value2) OR field IS NULL". @@ -379,7 +431,7 @@ public function notInOrNull( ExpressionInterface|array|string $values, ?string $type = null, ) { - $or = new static([], [], 'OR'); + $or = new static([], $this->getTypeMap(), 'OR'); $or ->notIn($field, $values, $type) ->isNull($field); @@ -426,6 +478,23 @@ public function between(ExpressionInterface|string $field, mixed $from, mixed $t return $this->add(new BetweenExpression($field, $from, $to, $type)); } + /** + * Adds a new condition to the expression object in the form + * "field NOT BETWEEN from AND to". + * + * @param \Cake\Database\ExpressionInterface|string $field The field name to compare for values outside the range. + * @param mixed $from The initial value of the range. + * @param mixed $to The ending value in the comparison range. + * @param string|null $type the type name for $value as configured using the Type map. + * @return $this + */ + public function notBetween(ExpressionInterface|string $field, mixed $from, mixed $to, ?string $type = null) + { + $type ??= $this->_calculateType($field); + + return $this->add(new BetweenExpression($field, $from, $to, $type, true)); + } + /** * Returns a new QueryExpression object containing all the conditions passed * and set up the conjunction to be "AND" @@ -690,12 +759,18 @@ protected function _parseCondition(string $condition, mixed $value): ExpressionI // like `CONCAT(first_name, ' ', last_name) IN`. if ($spaces > 1) { $parts = explode(' ', $expression); - if (preg_match('/(is not|not \w+)$/i', $expression)) { - $last = array_pop($parts); - $second = array_pop($parts); - $parts[] = "{$second} {$last}"; + if (preg_match('/is not distinct from$/i', $expression)) { + $operator = implode(' ', array_slice($parts, -4)); + $parts = array_slice($parts, 0, -4); + } elseif (preg_match('/is distinct from$/i', $expression)) { + $operator = implode(' ', array_slice($parts, -3)); + $parts = array_slice($parts, 0, -3); + } elseif (preg_match('/(is not|not \w+)$/i', $expression)) { + $operator = implode(' ', array_slice($parts, -2)); + $parts = array_slice($parts, 0, -2); + } else { + $operator = array_pop($parts); } - $operator = array_pop($parts); $expression = implode(' ', $parts); } elseif ($spaces === 1) { $parts = explode(' ', $expression, 2); @@ -743,11 +818,18 @@ protected function _parseCondition(string $condition, mixed $value): ExpressionI $operator = '!='; } - if ($value === null && $this->_conjunction !== ',') { + if (in_array($operator, ['IS DISTINCT FROM', 'IS NOT DISTINCT FROM'], true)) { + return new DistinctComparisonExpression($expression, $value, $type, $operator); + } + + if ( + $value === null && + $this->_conjunction !== ',' + ) { throw new InvalidArgumentException( sprintf( 'Expression `%s` has invalid `null` value.' - . ' If `null` is a valid value, operator (IS, IS NOT) is missing.', + . ' If `null` is a valid value, operator (IS, IS NOT, IS DISTINCT FROM, IS NOT DISTINCT FROM) is missing.', $expression, ), ); diff --git a/src/Database/Expression/StringAggExpression.php b/src/Database/Expression/StringAggExpression.php new file mode 100644 index 00000000000..f842be6b37c --- /dev/null +++ b/src/Database/Expression/StringAggExpression.php @@ -0,0 +1,205 @@ +|array $types Types for function arguments. + * @param \Cake\Database\ExpressionInterface|array|string|null $orderBy Aggregate-local ordering. + */ + public function __construct(array $params = [], array $types = [], ExpressionInterface|array|string|null $orderBy = null) + { + parent::__construct('STRING_AGG', $params, $types); + if ($orderBy !== null) { + $this->setAggregateOrderBy($orderBy); + } + } + + /** + * Sets aggregate-local ordering. + * + * @param \Cake\Database\ExpressionInterface|array|string $fields The sort columns. + * @return $this + */ + public function setAggregateOrderBy(ExpressionInterface|array|string $fields) + { + $this->aggregateOrderBy ??= new OrderByExpression(); + $this->aggregateOrderBy->add($fields); + + return $this; + } + + /** + * Sets the SQL syntax variant. + * + * @param string $syntax The syntax variant. + * @return $this + */ + public function setSyntax(string $syntax) + { + $allowed = [ + static::SYNTAX_STANDARD, + static::SYNTAX_WITHIN_GROUP, + static::SYNTAX_GROUP_CONCAT, + ]; + if (!in_array($syntax, $allowed, true)) { + throw new InvalidArgumentException(sprintf('Unsupported string aggregation syntax `%s`.', $syntax)); + } + + $this->syntax = $syntax; + + return $this; + } + + /** + * @inheritDoc + */ + public function sql(ValueBinder $binder): string + { + $parts = array_map(fn($part) => $this->stringifyPart($part, $binder), $this->_conditions); + [$value, $separator] = $parts + [null, null]; + + $sql = match ($this->syntax) { + static::SYNTAX_GROUP_CONCAT => $this->_name . sprintf( + '(%s%s SEPARATOR %s)', + $value, + $this->aggregateOrderBy ? ' ' . $this->aggregateOrderBy->sql($binder) : '', + $separator, + ), + static::SYNTAX_WITHIN_GROUP => $this->_name . sprintf( + '(%s, %s)%s', + $value, + $separator, + $this->aggregateOrderBy ? ' WITHIN GROUP (' . $this->aggregateOrderBy->sql($binder) . ')' : '', + ), + default => $this->_name . sprintf( + '(%s, %s%s)', + $value, + $separator, + $this->aggregateOrderBy ? ' ' . $this->aggregateOrderBy->sql($binder) : '', + ), + }; + + if ($this->filter !== null) { + $sql .= ' FILTER (WHERE ' . $this->filter->sql($binder) . ')'; + } + if ($this->window !== null) { + if ($this->window->isNamedOnly()) { + $sql .= ' OVER ' . $this->window->sql($binder); + } else { + $sql .= ' OVER (' . $this->window->sql($binder) . ')'; + } + } + + return $sql; + } + + /** + * @inheritDoc + */ + public function traverse(Closure $callback) + { + parent::traverse($callback); + if ($this->aggregateOrderBy !== null) { + $callback($this->aggregateOrderBy); + $this->aggregateOrderBy->traverse($callback); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function count(): int + { + $count = parent::count(); + if ($this->aggregateOrderBy !== null) { + $count += 1; + } + + return $count; + } + + /** + * Clone this object and its subtree of expressions. + */ + public function __clone() + { + parent::__clone(); + if ($this->aggregateOrderBy !== null) { + $this->aggregateOrderBy = clone $this->aggregateOrderBy; + } + } + + /** + * Converts a function argument into SQL. + * + * @param mixed $part Function argument. + * @param \Cake\Database\ValueBinder $binder Value binder. + * @return string + */ + protected function stringifyPart(mixed $part, ValueBinder $binder): string + { + if ($part instanceof Query) { + return sprintf('(%s)', $part->sql($binder)); + } + if ($part instanceof ExpressionInterface) { + return $part->sql($binder); + } + if (is_array($part)) { + $placeholder = $binder->placeholder('param'); + $binder->bind($placeholder, $part['value'], $part['type']); + + return $placeholder; + } + + return (string)$part; + } +} diff --git a/src/Database/FunctionsBuilder.php b/src/Database/FunctionsBuilder.php index 2afbc46b53b..12cb0cf1684 100644 --- a/src/Database/FunctionsBuilder.php +++ b/src/Database/FunctionsBuilder.php @@ -18,6 +18,7 @@ use Cake\Database\Expression\AggregateExpression; use Cake\Database\Expression\FunctionExpression; +use Cake\Database\Expression\StringAggExpression; use InvalidArgumentException; /** @@ -102,6 +103,27 @@ public function count(ExpressionInterface|string $expression, array $types = []) return $this->aggregate('COUNT', $this->toLiteralParam($expression), $types, 'integer'); } + /** + * Returns an AggregateExpression representing a portable string aggregation call. + * + * @param \Cake\Database\ExpressionInterface|string $expression The value to aggregate. + * @param string $separator The separator inserted between values. + * @param \Cake\Database\ExpressionInterface|array|string|null $orderBy Aggregate-local ordering. + * @param array $types List of types to bind to the arguments. + * @return \Cake\Database\Expression\StringAggExpression + */ + public function stringAgg( + ExpressionInterface|string $expression, + string $separator, + ExpressionInterface|array|string|null $orderBy = null, + array $types = [], + ): StringAggExpression { + $params = $this->toLiteralParam($expression); + $params[] = $separator; + + return new StringAggExpression($params, $types, $orderBy); + } + /** * Returns a FunctionExpression representing a string concatenation * diff --git a/src/Database/Query.php b/src/Database/Query.php index 359907ce874..923a9889548 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -121,6 +121,7 @@ abstract class Query implements ExpressionInterface, Stringable 'limit' => null, 'offset' => null, 'union' => [], + 'except' => [], 'epilog' => null, 'intersect' => [], ]; diff --git a/src/Database/Query/SelectQuery.php b/src/Database/Query/SelectQuery.php index b72c5d72139..8f601ac7309 100644 --- a/src/Database/Query/SelectQuery.php +++ b/src/Database/Query/SelectQuery.php @@ -68,6 +68,7 @@ class SelectQuery extends Query implements IteratorAggregate 'limit' => null, 'offset' => null, 'union' => [], + 'except' => [], 'epilog' => null, 'intersect' => [], ]; @@ -568,6 +569,79 @@ public function intersectAll(Query|string $query, bool $overwrite = false) return $this; } + /** + * Adds a complete query to be used in conjunction with an EXCEPT operator with + * this query. This is used to subtract the passed query from the result set of this query. + * You can add as many queries as you required by calling multiple times + * this method with different queries. + * + * By default, the EXCEPT operator will remove duplicate rows, if you wish to include + * every row for all queries, use exceptAll(). + * + * ### Examples + * + * ``` + * $except = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']); + * $query->select(['id', 'name'])->from(['d' => 'things'])->except($except); + * ``` + * + * Will produce: + * + * `SELECT id, name FROM things d EXCEPT SELECT id, title FROM articles a` + * + * @param \Cake\Database\Query|string $query full SQL query to be used in EXCEPT operator + * @param bool $overwrite whether to reset the list of queries to be operated or not + * @return $this + */ + public function except(Query|string $query, bool $overwrite = false) + { + if ($overwrite) { + $this->_parts['except'] = []; + } + $this->_parts['except'][] = [ + 'all' => false, + 'query' => $query, + ]; + $this->_dirty(); + + return $this; + } + + /** + * Adds a complete query to be used in conjunction with the EXCEPT ALL operator with + * this query. This is used to subtract the passed query from the result set of this query. + * You can add as many queries as you required by calling multiple times + * this method with different queries. + * + * Unlike EXCEPT, EXCEPT ALL will not remove duplicate rows. + * + * ``` + * $except = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']); + * $query->select(['id', 'name'])->from(['d' => 'things'])->exceptAll($except); + * ``` + * + * Will produce: + * + * `SELECT id, name FROM things d EXCEPT ALL SELECT id, title FROM articles a` + * + * @param \Cake\Database\Query|string $query full SQL query to be used in EXCEPT operator + * @param bool $overwrite whether to reset the list of queries to be operated or not + * @return $this + */ + public function exceptAll(Query|string $query, bool $overwrite = false) + { + if ($overwrite) { + $this->_parts['except'] = []; + } + $this->_parts['except'][] = [ + 'all' => true, + 'query' => $query, + ]; + $this->_dirty(); + + return $this; + } + /** * Executes this query and returns a results iterator. This function is required * for implementing the IteratorAggregate interface and allows the query to be diff --git a/src/Database/QueryCompiler.php b/src/Database/QueryCompiler.php index 181e15c6cf0..3be56131535 100644 --- a/src/Database/QueryCompiler.php +++ b/src/Database/QueryCompiler.php @@ -53,7 +53,7 @@ class QueryCompiler */ protected array $_selectParts = [ 'comment', 'with', 'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order', - 'limit', 'offset', 'union', 'epilog', 'intersect', + 'limit', 'offset', 'union', 'except', 'epilog', 'intersect', ]; /** @@ -191,7 +191,7 @@ protected function _buildSelectPart(array $parts, Query $query, ValueBinder $bin $driver = $query->getDriver(); $select = 'SELECT%s%s %s%s'; if ( - ($query->clause('union') || $query->clause('intersect')) && + ($query->clause('union') || $query->clause('except') || $query->clause('intersect')) && $driver->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY) ) { $select = '(SELECT%s%s %s%s'; @@ -396,6 +396,21 @@ protected function _buildIntersectPart(array $parts, Query $query, ValueBinder $ return $this->_buildSetOperationPart('INTERSECT', $parts, $query, $binder); } + /** + * Builds the SQL string for all the EXCEPT clauses in this query, when dealing + * with query objects it will also transform them using their configured SQL + * dialect. + * + * @param array $parts list of queries to be operated with EXCEPT + * @param \Cake\Database\Query $query The query that is being compiled + * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder + * @return string + */ + protected function _buildExceptPart(array $parts, Query $query, ValueBinder $binder): string + { + return $this->_buildSetOperationPart('EXCEPT', $parts, $query, $binder); + } + /** * Builds the SQL string for all the UNION clauses in this query, when dealing * with query objects it will also transform them using their configured SQL diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php index 3706ee67548..0a3d0400bf4 100644 --- a/src/Database/Schema/Column.php +++ b/src/Database/Schema/Column.php @@ -46,6 +46,7 @@ class Column * @param bool|null $unsigned Whether the column is unsigned * @param string|null $collate Collation for the column * @param int|null $srid SRID for geometry fields + * @param string|null $geometryType Geometry type for geometry fields (e.g., Point, Polygon) * @param string|null $baseType The basic schema type if the column type is a complex/custom type. * @param bool|null $fixed Whether the column is fixed-length (BINARY vs VARBINARY) */ @@ -65,6 +66,7 @@ public function __construct( protected ?bool $unsigned = null, protected ?string $collate = null, protected ?int $srid = null, + protected ?string $geometryType = null, protected ?string $baseType = null, protected ?bool $fixed = null, ) { @@ -505,6 +507,29 @@ public function getSrid(): ?int return $this->srid; } + /** + * Sets the geometry type for geometry fields. + * + * @param string $geometryType Geometry type (e.g., Point, Polygon) + * @return $this + */ + public function setGeometryType(string $geometryType) + { + $this->geometryType = $geometryType; + + return $this; + } + + /** + * Gets the geometry type for geometry fields. + * + * @return string|null + */ + public function getGeometryType(): ?string + { + return $this->geometryType; + } + /** * Sets whether the column is fixed-length. * @@ -562,6 +587,7 @@ protected function getValidOptions(): array 'properties', 'collate', 'srid', + 'geometryType', 'increment', 'generated', 'fixed', @@ -612,7 +638,7 @@ public function toArray(): array } } - return [ + $result = [ 'name' => $this->getName(), 'baseType' => $this->getBaseType(), 'type' => $type, @@ -630,5 +656,12 @@ public function toArray(): array 'identity' => $this->getIdentity(), 'fixed' => $this->getFixed(), ]; + + // Only include geometryType when set (for PostGIS reflection) + if ($this->getGeometryType() !== null) { + $result['geometryType'] = $this->getGeometryType(); + } + + return $result; } } diff --git a/src/Database/Schema/Index.php b/src/Database/Schema/Index.php index 83111e65511..9391530ebf0 100644 --- a/src/Database/Schema/Index.php +++ b/src/Database/Schema/Index.php @@ -38,6 +38,17 @@ class Index */ public const FULLTEXT = 'fulltext'; + /** + * PostgreSQL index access methods + * + * @var string + */ + public const GIN = 'gin'; + public const GIST = 'gist'; + public const SPGIST = 'spgist'; + public const BRIN = 'brin'; + public const HASH = 'hash'; + /** * Constructor * @@ -48,6 +59,7 @@ class Index * @param array|null $order The sort order of the index columns. * @param array|null $include The included columns for covering indexes. * @param ?string $where The where clause for partial indexes. + * @param ?string $accessMethod The index access method for PostgreSQL (gin, gist, spgist, brin, hash). */ public function __construct( protected string $name, @@ -57,6 +69,7 @@ public function __construct( protected ?array $order = null, protected ?array $include = null, protected ?string $where = null, + protected ?string $accessMethod = null, ) { } @@ -231,6 +244,32 @@ public function getWhere(): ?string return $this->where; } + /** + * Set the index access method for PostgreSQL. + * + * PostgreSQL supports multiple index access methods: btree (default), + * gin, gist, spgist, brin, and hash. + * + * @param ?string $accessMethod The access method (gin, gist, spgist, brin, hash). + * @return $this + */ + public function setAccessMethod(?string $accessMethod) + { + $this->accessMethod = $accessMethod; + + return $this; + } + + /** + * Get the index access method for PostgreSQL. + * + * @return ?string + */ + public function getAccessMethod(): ?string + { + return $this->accessMethod; + } + /** * Utility method that maps an array of index options to this object's methods. * @@ -241,7 +280,7 @@ public function getWhere(): ?string public function setAttributes(array $attributes) { // Valid Options - $validOptions = ['columns', 'type', 'name', 'length', 'order', 'include', 'where']; + $validOptions = ['columns', 'type', 'name', 'length', 'order', 'include', 'where', 'accessMethod']; foreach ($attributes as $attr => $value) { if (!in_array($attr, $validOptions, true)) { throw new RuntimeException(sprintf('"%s" is not a valid index option.', $attr)); @@ -260,7 +299,7 @@ public function setAttributes(array $attributes) */ public function toArray(): array { - return [ + $result = [ 'name' => $this->getName(), 'columns' => $this->getColumns(), 'type' => $this->getType(), @@ -269,5 +308,11 @@ public function toArray(): array 'include' => $this->getInclude(), 'where' => $this->getWhere(), ]; + // Only include accessMethod when set (PostgreSQL-specific) + if ($this->accessMethod !== null) { + $result['accessMethod'] = $this->accessMethod; + } + + return $result; } } diff --git a/src/Database/Schema/PostgresSchemaDialect.php b/src/Database/Schema/PostgresSchemaDialect.php index 0357240baa4..beef53a022c 100644 --- a/src/Database/Schema/PostgresSchemaDialect.php +++ b/src/Database/Schema/PostgresSchemaDialect.php @@ -111,6 +111,27 @@ private function describeColumnQuery(): string ORDER BY ordinal_position'; } + /** + * Describes PostGIS specific column information. + * + * @return array The column information. + */ + private function describePostgisColumns(string $postgisType, string $table, string $schema, string $catalog): array + { + $sql = <<_driver->execute($sql, [$table, $schema, $catalog])->fetchAll('assoc'); + + return array_combine(array_column($columns, 'name'), $columns); + } + /** * Convert a column definition to the abstract types. * @@ -279,9 +300,18 @@ public function describeColumns(string $tableName): array [$schema, $name] = $this->splitTablename($tableName); $sql = $this->describeColumnQuery(); - $statement = $this->_driver->execute($sql, [$name, $schema, $config['database']]); + $rows = $this->_driver->execute($sql, [$name, $schema, $config['database']])->fetchAll('assoc'); + + $postgisColumns = []; + $udtTypes = array_column($rows, 'udt_name'); + foreach (['geometry', 'geography'] as $postgisType) { + if (in_array($postgisType, $udtTypes)) { + $postgisColumns += $this->describePostgisColumns($postgisType, $name, $schema, $config['database']); + } + } + $columns = []; - foreach ($statement->fetchAll('assoc') as $row) { + foreach ($rows as $row) { $type = $row['type']; if ($type === 'USER-DEFINED') { $type = $row['udt_name']; @@ -326,6 +356,12 @@ public function describeColumns(string $tableName): array $field['generated'] = $row['identity_generation']; } + // Add PostGIS metadata for geometry/geography columns + if (isset($postgisColumns[$row['name']])) { + $field['geometryType'] = ucfirst(strtolower($postgisColumns[$row['name']]['type'])); + $field['srid'] = $postgisColumns[$row['name']]['srid']; + } + $columns[] = $field; } @@ -375,12 +411,14 @@ private function describeIndexQuery(): string a.attname, i.indisprimary, i.indisunique, - i.indnkeyatts + i.indnkeyatts, + am.amname FROM pg_catalog.pg_namespace n INNER JOIN pg_catalog.pg_class c ON (n.oid = c.relnamespace) INNER JOIN pg_catalog.pg_index i ON (c.oid = i.indrelid) INNER JOIN pg_catalog.pg_class c2 ON (c2.oid = i.indexrelid) INNER JOIN pg_catalog.pg_attribute a ON (a.attrelid = c.oid AND i.indrelid::regclass = a.attrelid::regclass) + INNER JOIN pg_catalog.pg_am am ON (c2.relam = am.oid) WHERE n.nspname = ? AND a.attnum = ANY(i.indkey) AND c.relname = ? @@ -423,6 +461,11 @@ public function convertIndexDescription(TableSchema $schema, array $row): void 'type' => $type, 'columns' => [], ]; + // Include access method for non-btree indexes + $accessMethod = $row['amname'] ?? 'btree'; + if ($accessMethod !== 'btree') { + $index['accessMethod'] = $accessMethod; + } } $index['columns'][] = $row['attname']; $schema->addIndex($name, $index); @@ -458,6 +501,11 @@ public function describeIndexes(string $tableName): array 'columns' => [], 'length' => [], ]; + // Include access method for non-btree indexes + $accessMethod = $row['amname'] ?? 'btree'; + if ($accessMethod !== 'btree') { + $indexes[$name]['accessMethod'] = $accessMethod; + } } if ($constraint) { $indexes[$name]['constraint'] = $constraint; @@ -917,6 +965,14 @@ public function indexSql(TableSchema $schema, string $name): string $this->_driver->quoteIdentifier(...), (array)$index->getColumns(), ); + + // Build USING clause for non-btree access methods (gin, gist, spgist, brin, hash) + $using = ''; + $accessMethod = $index->getAccessMethod(); + if ($accessMethod !== null) { + $using = ' USING ' . $accessMethod; + } + $include = ''; if ($index->getInclude()) { $included = array_map( @@ -927,9 +983,10 @@ public function indexSql(TableSchema $schema, string $name): string } return sprintf( - 'CREATE INDEX %s ON %s (%s)%s', + 'CREATE INDEX %s ON %s%s (%s)%s', $this->_driver->quoteIdentifier($name), $this->_driver->quoteIdentifier($schema->name()), + $using, implode(', ', $columns), $include, ); diff --git a/src/Database/Schema/TableSchema.php b/src/Database/Schema/TableSchema.php index 49c7b4479c7..6dcaf60b148 100644 --- a/src/Database/Schema/TableSchema.php +++ b/src/Database/Schema/TableSchema.php @@ -172,15 +172,23 @@ class TableSchema implements TableSchemaInterface, SqlGeneratorInterface 'unsigned' => null, ], 'geometry' => [ + 'geometryType' => null, + 'srid' => null, + ], + 'geography' => [ + 'geometryType' => null, 'srid' => null, ], 'point' => [ + 'geometryType' => null, 'srid' => null, ], 'linestring' => [ + 'geometryType' => null, 'srid' => null, ], 'polygon' => [ + 'geometryType' => null, 'srid' => null, ], 'datetime' => [ @@ -220,6 +228,7 @@ class TableSchema implements TableSchemaInterface, SqlGeneratorInterface 'constraint' => null, 'deferrable' => null, 'expression' => null, + 'accessMethod' => null, ]; /** @@ -835,7 +844,7 @@ protected function _checkForeignKey(array $attrs): array // Map the backwards compatible attributes in. Need to check for existing instance. $attrs['referencedTable'] = $attrs['references'][0]; $attrs['referencedColumns'] = (array)$attrs['references'][1]; - unset($attrs['type'], $attrs['references'], $attrs['length'], $attrs['expression']); + unset($attrs['type'], $attrs['references'], $attrs['length'], $attrs['expression'], $attrs['accessMethod']); return $attrs; } diff --git a/src/Database/SqlserverCompiler.php b/src/Database/SqlserverCompiler.php index e0f15b7d5e0..9cf5b4d1223 100644 --- a/src/Database/SqlserverCompiler.php +++ b/src/Database/SqlserverCompiler.php @@ -49,7 +49,7 @@ class SqlserverCompiler extends QueryCompiler */ protected array $_selectParts = [ 'comment', 'with', 'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order', - 'offset', 'limit', 'union', 'epilog', 'intersect', + 'offset', 'limit', 'union', 'except', 'epilog', 'intersect', ]; /** diff --git a/src/Database/Type/DecimalType.php b/src/Database/Type/DecimalType.php index 496bc18c2f1..81a958ebda4 100644 --- a/src/Database/Type/DecimalType.php +++ b/src/Database/Type/DecimalType.php @@ -175,12 +175,17 @@ public function useLocaleParser(bool $enable = true) * the locale aware parser. * * @param string $value The value to parse and convert to an float. - * @return string + * @return string|null */ - protected function _parseValue(string $value): string + protected function _parseValue(string $value): ?string { $class = static::$numberClass; + $result = $class::parseFloat($value); + + if ($result === null) { + return null; + } - return (string)$class::parseFloat($value); + return (string)$result; } } diff --git a/src/Database/Type/FloatType.php b/src/Database/Type/FloatType.php index c976cca6e6e..7c80d3cb3b9 100644 --- a/src/Database/Type/FloatType.php +++ b/src/Database/Type/FloatType.php @@ -155,9 +155,9 @@ public function useLocaleParser(bool $enable = true) * aware parser. * * @param string $value The value to parse and convert to an float. - * @return float + * @return float|null */ - protected function _parseValue(string $value): float + protected function _parseValue(string $value): ?float { $class = static::$numberClass; diff --git a/src/Database/composer.json b/src/Database/composer.json index 405232f5983..4216e736517 100644 --- a/src/Database/composer.json +++ b/src/Database/composer.json @@ -25,14 +25,14 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0", + "cakephp/core": "5.4.*@dev", "cakephp/chronos": "^3.3", - "cakephp/datasource": "^5.3.0", + "cakephp/datasource": "5.4.*@dev", "psr/log": "^3.0" }, "require-dev": { - "cakephp/i18n": "^5.3.0", - "cakephp/log": "^5.3.0" + "cakephp/i18n": "5.4.*@dev", + "cakephp/log": "5.4.*@dev" }, "autoload": { "psr-4": { diff --git a/src/Datasource/composer.json b/src/Datasource/composer.json index 57273ee7ce7..aa4f557b92b 100644 --- a/src/Datasource/composer.json +++ b/src/Datasource/composer.json @@ -25,13 +25,13 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0", + "cakephp/core": "5.4.*@dev", "psr/simple-cache": "^2.0 || ^3.0" }, "require-dev": { - "cakephp/cache": "^5.3.0", - "cakephp/collection": "^5.3.0", - "cakephp/utility": "^5.3.0" + "cakephp/cache": "5.4.*@dev", + "cakephp/collection": "5.4.*@dev", + "cakephp/utility": "5.4.*@dev" }, "autoload": { "psr-4": { diff --git a/src/Event/composer.json b/src/Event/composer.json index 942616483c2..d4ef7730345 100644 --- a/src/Event/composer.json +++ b/src/Event/composer.json @@ -24,7 +24,7 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0" + "cakephp/core": "5.4.*@dev" }, "autoload": { "psr-4": { diff --git a/src/Form/composer.json b/src/Form/composer.json index fecc6fb0ddf..86d018454db 100644 --- a/src/Form/composer.json +++ b/src/Form/composer.json @@ -22,8 +22,8 @@ }, "require": { "php": ">=8.2", - "cakephp/event": "^5.3.0", - "cakephp/validation":"^5.3.0" + "cakephp/event": "5.4.*@dev", + "cakephp/validation":"5.4.*@dev" }, "autoload": { "psr-4": { diff --git a/src/Http/BaseApplication.php b/src/Http/BaseApplication.php index 0a073bb0407..4d464484a59 100644 --- a/src/Http/BaseApplication.php +++ b/src/Http/BaseApplication.php @@ -20,8 +20,8 @@ use Cake\Console\CommandCollection; use Cake\Controller\ControllerFactory; use Cake\Core\ConsoleApplicationInterface; -use Cake\Core\Container; use Cake\Core\ContainerApplicationInterface; +use Cake\Core\ContainerFactory; use Cake\Core\ContainerInterface; use Cake\Core\EventAwareApplicationInterface; use Cake\Core\Exception\MissingPluginException; @@ -291,11 +291,15 @@ public function getContainer(): ContainerInterface * Override this method if you need to use a custom container or * want to change how the container is built. * + * The container type is determined by `Configure::read('App.container')`: + * - 'cake': Uses the built-in CakePHP container + * - Any other value: Uses the League container (default) + * * @return \Cake\Core\ContainerInterface */ protected function buildContainer(): ContainerInterface { - $container = new Container(); + $container = ContainerFactory::create(); $this->services($container); foreach ($this->plugins->with('services') as $plugin) { $plugin->services($container); diff --git a/src/Http/Link/Link.php b/src/Http/Link/Link.php new file mode 100644 index 00000000000..0d748ce0ca8 --- /dev/null +++ b/src/Http/Link/Link.php @@ -0,0 +1,158 @@ +withRel('collection'); + * $link = $link->withAttribute('type', 'application/json'); + * ``` + */ +class Link implements EvolvableLinkInterface +{ + /** + * The link relations. + * + * @var array + */ + private array $rels; + + /** + * The link attributes. + * + * @var array> + */ + private array $attributes; + + /** + * Constructor. + * + * @param string $href The link URI. + * @param array|string $rels The link relation(s). + * @param array> $attributes Additional attributes. + */ + public function __construct( + private string $href = '', + string|array $rels = [], + array $attributes = [], + ) { + $this->rels = is_string($rels) ? [$rels] : $rels; + $this->attributes = $attributes; + } + + /** + * @inheritDoc + */ + public function getHref(): string + { + return $this->href; + } + + /** + * @inheritDoc + */ + public function isTemplated(): bool + { + return str_contains($this->href, '{') && str_contains($this->href, '}'); + } + + /** + * @inheritDoc + */ + public function getRels(): array + { + return $this->rels; + } + + /** + * @inheritDoc + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * @inheritDoc + */ + public function withHref(string|Stringable $href): static + { + $new = clone $this; + $new->href = (string)$href; + + return $new; + } + + /** + * @inheritDoc + */ + public function withRel(string $rel): static + { + $new = clone $this; + if (!in_array($rel, $new->rels, true)) { + $new->rels[] = $rel; + } + + return $new; + } + + /** + * @inheritDoc + */ + public function withoutRel(string $rel): static + { + $new = clone $this; + $new->rels = array_values(array_filter( + $new->rels, + fn(string $r): bool => $r !== $rel, + )); + + return $new; + } + + /** + * @inheritDoc + */ + public function withAttribute(string $attribute, string|Stringable|int|float|bool|array $value): static + { + $new = clone $this; + $new->attributes[$attribute] = $value instanceof Stringable ? (string)$value : $value; + + return $new; + } + + /** + * @inheritDoc + */ + public function withoutAttribute(string $attribute): static + { + $new = clone $this; + unset($new->attributes[$attribute]); + + return $new; + } +} diff --git a/src/Http/Link/LinkProvider.php b/src/Http/Link/LinkProvider.php new file mode 100644 index 00000000000..f11804f6294 --- /dev/null +++ b/src/Http/Link/LinkProvider.php @@ -0,0 +1,113 @@ +getLinks() as $link) { + * echo $link->getHref(); + * } + * ``` + */ +class LinkProvider implements EvolvableLinkProviderInterface +{ + /** + * The links. + * + * @var array<\Psr\Link\LinkInterface> + */ + private array $links; + + /** + * Constructor. + * + * @param iterable<\Psr\Link\LinkInterface> $links Initial links. + */ + public function __construct(iterable $links = []) + { + $this->links = $links instanceof Traversable + ? iterator_to_array($links) + : $links; + } + + /** + * @inheritDoc + */ + public function getLinks(): iterable + { + return $this->links; + } + + /** + * @inheritDoc + */ + public function getLinksByRel(string $rel): iterable + { + return array_filter( + $this->links, + fn(LinkInterface $link): bool => in_array($rel, $link->getRels(), true), + ); + } + + /** + * @inheritDoc + */ + public function withLink(LinkInterface $link): static + { + $new = clone $this; + + // Check if link already exists (by reference) + foreach ($new->links as $existing) { + if ($existing === $link) { + return $new; + } + } + + $new->links[] = $link; + + return $new; + } + + /** + * @inheritDoc + */ + public function withoutLink(LinkInterface $link): static + { + $new = clone $this; + $new->links = array_values(array_filter( + $new->links, + fn(LinkInterface $l): bool => $l !== $link, + )); + + return $new; + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php index 29f84f62443..5bed418e66c 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -20,6 +20,7 @@ use Cake\Http\Cookie\CookieCollection; use Cake\Http\Cookie\CookieInterface; use Cake\Http\Exception\NotFoundException; +use Cake\Http\Link\LinkProvider; use Cake\I18n\DateTime as CakeDateTime; use DateTime; use DateTimeInterface; @@ -29,6 +30,8 @@ use Laminas\Diactoros\Stream; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use Psr\Link\EvolvableLinkProviderInterface; +use Psr\Link\LinkInterface; use SplFileInfo; use Stringable; use function Cake\Core\env; @@ -173,6 +176,13 @@ class Response implements ResponseInterface, Stringable */ protected CookieCollection $_cookies; + /** + * Collection of hypermedia links (PSR-13). + * + * @var \Psr\Link\EvolvableLinkProviderInterface + */ + protected EvolvableLinkProviderInterface $links; + /** * Reason Phrase * @@ -231,6 +241,7 @@ public function __construct(array $options = []) } $this->_setContentType($type); $this->_cookies = new CookieCollection(); + $this->links = new LinkProvider(); } /** @@ -1087,6 +1098,71 @@ public function withCookieCollection(CookieCollection $cookieCollection): static return $new; } + /** + * Create a new response with a hypermedia link added. + * + * ### Example + * + * ``` + * use Cake\Http\Link\Link; + * + * $response = $response->withLink(new Link('/api/users', 'self')); + * $response = $response->withLink( + * (new Link('/css/app.css')) + * ->withRel('preload') + * ->withAttribute('as', 'style') + * ); + * ``` + * + * @param \Psr\Link\LinkInterface $link The link to add. + * @return static + */ + public function withLink(LinkInterface $link): static + { + $new = clone $this; + $new->links = $new->links->withLink($link); + + return $new; + } + + /** + * Create a new response without a specific hypermedia link. + * + * @param \Psr\Link\LinkInterface $link The link to remove. + * @return static + */ + public function withoutLink(LinkInterface $link): static + { + $new = clone $this; + $new->links = $new->links->withoutLink($link); + + return $new; + } + + /** + * Get the link provider containing all hypermedia links. + * + * @return \Psr\Link\EvolvableLinkProviderInterface + */ + public function getLinks(): EvolvableLinkProviderInterface + { + return $this->links; + } + + /** + * Get a new instance with provided link provider. + * + * @param \Psr\Link\EvolvableLinkProviderInterface $links Link provider to set. + * @return static + */ + public function withLinkProvider(EvolvableLinkProviderInterface $links): static + { + $new = clone $this; + $new->links = $links; + + return $new; + } + /** * Get a CorsBuilder instance for defining CORS headers. * @@ -1273,6 +1349,7 @@ public function __debugInfo(): array 'file' => $this->_file, 'fileRange' => $this->_fileRange, 'cookies' => $this->_cookies, + 'links' => $this->links, 'cacheDirectives' => $this->_cacheDirectives, 'body' => (string)$this->getBody(), ]; diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php index 30f63682096..5d7f28bb55c 100644 --- a/src/Http/ResponseEmitter.php +++ b/src/Http/ResponseEmitter.php @@ -24,6 +24,7 @@ use Cake\Http\Cookie\CookieInterface; use Laminas\Diactoros\RelativeStream; use Psr\Http\Message\ResponseInterface; +use Psr\Link\LinkInterface; /** * Emits a Response to the PHP Server API. @@ -198,6 +199,73 @@ protected function emitHeaders(ResponseInterface $response): void } $this->emitCookies($cookies); + $this->emitLinks($response); + } + + /** + * Emit PSR-13 links as HTTP Link headers. + * + * @param \Psr\Http\Message\ResponseInterface $response The response to emit. + * @return void + */ + protected function emitLinks(ResponseInterface $response): void + { + if (!$response instanceof Response) { + return; + } + + $links = $response->getLinks()->getLinks(); + foreach ($links as $link) { + header(sprintf('Link: %s', $this->formatLink($link)), false); + } + } + + /** + * Format a PSR-13 link as an HTTP Link header value. + * + * @param \Psr\Link\LinkInterface $link The link to format. + * @return string The formatted header value. + * @link https://www.rfc-editor.org/rfc/rfc8288 Web Linking (RFC 8288) + */ + protected function formatLink(LinkInterface $link): string + { + $parts = ['<' . $link->getHref() . '>']; + + $rels = $link->getRels(); + if ($rels) { + $parts[] = 'rel="' . implode(' ', $rels) . '"'; + } + + foreach ($link->getAttributes() as $key => $value) { + if (is_bool($value)) { + if ($value) { + $parts[] = $key; + } + continue; + } + + if (is_array($value)) { + foreach ($value as $v) { + $parts[] = $key . '="' . $this->escapeHeaderValue((string)$v) . '"'; + } + continue; + } + + $parts[] = $key . '="' . $this->escapeHeaderValue((string)$value) . '"'; + } + + return implode('; ', $parts); + } + + /** + * Escape a header value for use in a quoted string. + * + * @param string $value The value to escape. + * @return string The escaped value. + */ + protected function escapeHeaderValue(string $value): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); } /** diff --git a/src/Http/composer.json b/src/Http/composer.json index 4478048f92e..02f65b4e95c 100644 --- a/src/Http/composer.json +++ b/src/Http/composer.json @@ -1,11 +1,12 @@ { "name": "cakephp/http", - "description": "CakePHP HTTP client and PSR-7, PSR-15, PSR-17, PSR-18 compliant libraries", + "description": "CakePHP HTTP client and PSR-7, PSR-13, PSR-15, PSR-17, PSR-18 compliant libraries", "type": "library", "keywords": [ "cakephp", "http", "PSR-7", + "PSR-13", "PSR-15", "PSR-17", "PSR-18" @@ -26,23 +27,24 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0", - "cakephp/event": "^5.3.0", - "cakephp/utility": "^5.3.0", + "cakephp/core": "5.4.*@dev", + "cakephp/event": "5.4.*@dev", + "cakephp/utility": "5.4.*@dev", "composer/ca-bundle": "^1.5", "psr/http-client": "^1.0.2", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0.2", "psr/http-server-middleware": "^1.0.2", + "psr/link": "^2.0", "laminas/laminas-diactoros": "^3.8", "laminas/laminas-httphandlerrunner": "^2.6" }, "require-dev": { - "cakephp/cache": "^5.3.0", - "cakephp/console": "^5.3.0", - "cakephp/orm": "^5.3.0", - "cakephp/i18n": "^5.3.0", + "cakephp/cache": "5.4.*@dev", + "cakephp/console": "5.4.*@dev", + "cakephp/orm": "5.4.*@dev", + "cakephp/i18n": "5.4.*@dev", "paragonie/csp-builder": "^3.0" }, "autoload": { @@ -54,7 +56,8 @@ "psr/http-client-implementation": "^1.0", "psr/http-factory-implementation": "^1.1", "psr/http-server-handler-implementation": "^1.0", - "psr/http-server-middleware-implementation": "^1.0" + "psr/http-server-middleware-implementation": "^1.0", + "psr/link-implementation": "^2.0" }, "suggest": { "cakephp/cache": "To use cache session storage", diff --git a/src/I18n/Number.php b/src/I18n/Number.php index a264c871c5d..f06fbbc8924 100644 --- a/src/I18n/Number.php +++ b/src/I18n/Number.php @@ -69,6 +69,13 @@ class Number */ protected static ?string $_defaultCurrencyFormat = null; + /** + * Default units used by Number::toReadableSize() + * + * @var bool + */ + protected static bool $useIecUnits = false; + /** * Formats a number with a level of precision. * @@ -90,26 +97,51 @@ public static function precision(string|float|int $value, int $precision = 3, ar } /** - * Returns a formatted-for-humans file size. + * Returns a formatted-for-humans file size. By default, the units are exponents of ten (KB, MB, etc.). + * setUseIecUnits() can be used to swap to ISO/IEC 80000-13 units (KiB, MiB, etc). + * 1 KiB = 1024 Bytes + * 1 KB = 1000 Bytes * * @param string|float|int $size Size in bytes + * @param bool|null $useIecUnits Whether to use exponent of two or ten for units (KiB, MiB, etc. or KB, MB, etc.) * @return string Human readable size * @link https://book.cakephp.org/5/en/core-libraries/number.html#interacting-with-human-readable-values */ - public static function toReadableSize(string|float|int $size): string + public static function toReadableSize(string|float|int $size, ?bool $useIecUnits = null): string { + $useIec = $useIecUnits ?? static::$useIecUnits; + + $units = $useIec + ? ['KiB', 'MiB', 'GiB', 'TiB'] + : ['KB', 'MB', 'GB', 'TB']; + + $divisor = $useIec ? 1024 : 1000; + $size = (int)$size; return match (true) { - $size < 1024 => __dn('cake', '{0,number,integer} Byte', '{0,number,integer} Bytes', $size, $size), - round($size / 1024) < 1024 => __d('cake', '{0,number,#,###.##} KB', $size / 1024), - round($size / 1024 / 1024, 2) < 1024 => __d('cake', '{0,number,#,###.##} MB', $size / 1024 / 1024), - round($size / 1024 / 1024 / 1024, 2) < 1024 => - __d('cake', '{0,number,#,###.##} GB', $size / 1024 / 1024 / 1024), - default => __d('cake', '{0,number,#,###.##} TB', $size / 1024 / 1024 / 1024 / 1024), + $size < $divisor => __dn('cake', '{0,number,integer} Byte', '{0,number,integer} Bytes', $size, $size), + round($size / $divisor) < $divisor => __d('cake', '{0,number,#,###.##} {1}', $size / $divisor, $units[0]), + round($size / pow($divisor, 2), 2) < $divisor => + __d('cake', '{0,number,#,###.##} {1}', $size / pow($divisor, 2), $units[1]), + round($size / pow($divisor, 3), 2) < $divisor => + __d('cake', '{0,number,#,###.##} {1}', $size / pow($divisor, 3), $units[2]), + default => __d('cake', '{0,number,#,###.##} {1}', $size / pow($divisor, 4), $units[3]), }; } + /** + * Setter for units to use in toReadableSize(). If set to true, it will use IEC units, as defined in ISO/IEC 80000-13 + * (KiB, MiB, etc.). Else it will use natural units (MB, KB, etc). + * + * @param bool $useIec Whether to use exponents of two or ten for units (KiB, MiB, etc. or KB, MB, etc.) {@link toReadableSize()} + * @return void + */ + public static function setUseIecUnits(bool $useIec): void + { + static::$useIecUnits = $useIec; + } + /** * Formats a number into a percentage string. * @@ -169,13 +201,18 @@ public static function format(string|float|int $value, array $options = []): str * * @param string $value A numeric string. * @param array $options An array with options. - * @return float point number + * @return float|null Parsed float or null if parsing failed. */ - public static function parseFloat(string $value, array $options = []): float + public static function parseFloat(string $value, array $options = []): ?float { $formatter = static::formatter($options); + $result = $formatter->parse($value, NumberFormatter::TYPE_DOUBLE); + + if ($result === false) { + return null; + } - return (float)$formatter->parse($value, NumberFormatter::TYPE_DOUBLE); + return (float)$result; } /** diff --git a/src/I18n/composer.json b/src/I18n/composer.json index 16755c4af6f..dc66ed3f455 100644 --- a/src/I18n/composer.json +++ b/src/I18n/composer.json @@ -30,7 +30,7 @@ "require": { "php": ">=8.2", "ext-intl": "*", - "cakephp/core": "^5.3.0", + "cakephp/core": "5.4.*@dev", "cakephp/chronos": "^3.3" }, "autoload": { diff --git a/src/Log/composer.json b/src/Log/composer.json index a794a534254..f605732a31e 100644 --- a/src/Log/composer.json +++ b/src/Log/composer.json @@ -24,7 +24,7 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0", + "cakephp/core": "5.4.*@dev", "psr/log": "^3.0" }, "autoload": { diff --git a/src/Mailer/Mailer.php b/src/Mailer/Mailer.php index 3f4b32c6250..c4c96f5667a 100644 --- a/src/Mailer/Mailer.php +++ b/src/Mailer/Mailer.php @@ -140,6 +140,7 @@ class Mailer implements EventListenerInterface * Mailer's name. * * @var string + * @deprecated 5.4.0 This property is unused. */ public static string $name; diff --git a/src/ORM/Association/BelongsToMany.php b/src/ORM/Association/BelongsToMany.php index d9fd2c9ab64..e6072495cf9 100644 --- a/src/ORM/Association/BelongsToMany.php +++ b/src/ORM/Association/BelongsToMany.php @@ -69,7 +69,7 @@ class BelongsToMany extends Association * * @var string */ - protected string $_strategy = self::STRATEGY_SELECT; + protected string $_strategy = self::STRATEGY_SUBQUERY; /** * Junction table instance diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php index 3b352b1dbf3..f12b4560d9a 100644 --- a/src/ORM/Association/HasMany.php +++ b/src/ORM/Association/HasMany.php @@ -59,7 +59,7 @@ class HasMany extends Association * * @var string */ - protected string $_strategy = self::STRATEGY_SELECT; + protected string $_strategy = self::STRATEGY_SUBQUERY; /** * Valid strategies for this type of association diff --git a/src/ORM/Association/Loader/SelectLoader.php b/src/ORM/Association/Loader/SelectLoader.php index 866e6ac7fbd..012a33fbc7a 100644 --- a/src/ORM/Association/Loader/SelectLoader.php +++ b/src/ORM/Association/Loader/SelectLoader.php @@ -17,7 +17,9 @@ namespace Cake\ORM\Association\Loader; use Cake\Database\Exception\DatabaseException; +use Cake\Database\Expression\AggregateExpression; use Cake\Database\Expression\IdentifierExpression; +use Cake\Database\Expression\QueryExpression; use Cake\Database\Expression\TupleComparison; use Cake\Database\ExpressionInterface; use Cake\Database\ValueBinder; @@ -444,6 +446,10 @@ protected function _buildSubquery(SelectQuery $query): SelectQuery * those columns are also included as the fields may be calculated or constant values, * that need to be present to ensure the correct association data is loaded. * + * When a HAVING clause is present the original SELECT aliases are preserved as + * well, since HAVING may reference computed aliases that would otherwise be + * dropped from the reduced subquery SELECT list. + * * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to get fields from. * @return array The list of fields for the subquery. */ @@ -459,10 +465,11 @@ protected function _subqueryFields(SelectQuery $query): array $group = array_values($fields); $fields = $group; - /** @var \Cake\Database\Expression\QueryExpression $order */ + $columns = $query->clause('select'); + + /** @var \Cake\Database\Expression\QueryExpression|null $order */ $order = $query->clause('order'); if ($order) { - $columns = $query->clause('select'); $order->iterateParts(function ($direction, $field) use (&$fields, $columns): void { if (isset($columns[$field])) { $fields[$field] = $columns[$field]; @@ -470,6 +477,41 @@ protected function _subqueryFields(SelectQuery $query): array }); } + $having = $query->clause('having'); + if ($having instanceof QueryExpression && $having->count() > 0) { + $reserved = []; + foreach ($keys as $k) { + $reserved[strtolower(trim((string)$k, '`"[]'))] = true; + } + + $havingSql = $having->sql(new ValueBinder()); + foreach ($columns as $alias => $column) { + if (!is_string($alias) || isset($fields[$alias])) { + continue; + } + $cleanAlias = trim($alias, '`"[]'); + if (isset($reserved[strtolower($cleanAlias)])) { + continue; + } + if (preg_match('/\b' . preg_quote($cleanAlias, '/') . '\b/', $havingSql) !== 1) { + continue; + } + $fields[$alias] = $column; + + $isAggregate = $column instanceof AggregateExpression; + if (!$isAggregate && $column instanceof ExpressionInterface) { + $column->traverse(function ($sub) use (&$isAggregate): void { + if ($sub instanceof AggregateExpression) { + $isAggregate = true; + } + }); + } + if (!$isAggregate) { + $group[] = $column; + } + } + } + return ['select' => $fields, 'group' => $group]; } diff --git a/src/ORM/AssociationsNormalizerTrait.php b/src/ORM/AssociationsNormalizerTrait.php index 6f869d2196a..a85c5549758 100644 --- a/src/ORM/AssociationsNormalizerTrait.php +++ b/src/ORM/AssociationsNormalizerTrait.php @@ -24,7 +24,12 @@ trait AssociationsNormalizerTrait { /** * Returns an array out of the original passed associations list where dot notation - * is transformed into nested arrays so that they can be parsed by other routines + * is transformed into nested arrays so that they can be parsed by other routines. + * + * This method now supports the same nested array format as contain(), allowing: + * - Dot notation: ['First.Second'] + * - Nested arrays: ['First' => ['Second', 'Third']] + * - Mixed with options: ['First' => ['Second', 'onlyIds' => true]] * * @param array|string $associations The array of included associations. * @return array An array having dot notation transformed into nested arrays @@ -40,6 +45,16 @@ protected function _normalizeAssociations(array|string $associations): array $options = []; } + // Handle nested array format like contain() + // Only transform if the array looks like it contains associations (not just a simple array value) + if (is_array($options) && !isset($options['associated']) && $this->_shouldExtractAssociations($options)) { + [$nestedAssociations, $actualOptions] = $this->_extractAssociations($options); + if ($nestedAssociations) { + $actualOptions['associated'] = $this->_normalizeAssociations($nestedAssociations); + } + $options = $actualOptions; + } + if (!str_contains($table, '.')) { $result[$table] = $options; continue; @@ -67,4 +82,95 @@ protected function _normalizeAssociations(array|string $associations): array return $result['associated'] ?? $result; } + + /** + * Determines if an array should have associations extracted from it. + * + * Returns true if the array appears to be mixing association names with options, + * or if it contains nested association structures (like contain() format). + * Returns false for simple arrays that should be kept as-is. + * + * Uses CakePHP naming conventions to detect associations vs options: + * - Association names start with uppercase (CamelCase): Users, Articles + * - Option keys start with lowercase (camelCase): onlyIds, conditions + * - Special data keys start with underscore: _joinData, _ids + * + * @param array $options The options array to check. + * @return bool + */ + protected function _shouldExtractAssociations(array $options): bool + { + // Empty arrays should not be transformed + if (!$options) { + return false; + } + + $hasOptionKey = false; + $hasStringKeys = false; + $hasNestedArrayValues = false; + $hasMultipleItems = count($options) > 1; + + foreach ($options as $key => $value) { + if (is_string($key)) { + $hasStringKeys = true; + // Option keys start with lowercase letter (camelCase convention) + if (preg_match('/^[a-z]/', $key)) { + $hasOptionKey = true; + } + } + // Check if value is an array (potential nested association) + if (is_array($value)) { + $hasNestedArrayValues = true; + } + } + + // Only extract associations if: + // 1. We have an option key (mixing associations and options) + // 2. We have string keys AND nested array values (contain-like format with nested associations) + // 3. We have multiple items (likely a list of associations like ['Users', 'Comments']) + return $hasOptionKey || ($hasStringKeys && $hasNestedArrayValues) || $hasMultipleItems; + } + + /** + * Extracts association names from options array, separating them from actual options. + * + * Uses CakePHP naming conventions to distinguish associations from options: + * - Association names start with uppercase (CamelCase): Users, Articles + * - Special data keys start with underscore: _joinData, _ids (treated as associations) + * - Option keys start with lowercase (camelCase): onlyIds, conditions + * + * This allows the same nested array format as contain(): + * - ['Users', 'Comments'] → associations + * - ['Users' => [...], 'Comments'] → associations + * - ['onlyIds' => true, 'validate' => false] → options only + * - ['Users', 'onlyIds' => true] → mixed + * + * @param array $options The options array that may contain nested associations. + * @return array An array with two elements: [associations, options] + */ + protected function _extractAssociations(array $options): array + { + $associations = []; + $actualOptions = []; + + foreach ($options as $key => $value) { + // Numeric keys are always association names (string values like 'Users') + if (is_int($key)) { + $associations[] = $value; + continue; + } + + // String keys starting with uppercase or underscore are associations/data keys + // This follows CakePHP conventions: CamelCase for models, _prefix for special data + if (preg_match('/^[A-Z_]/', $key)) { + $associations[$key] = $value; + continue; + } + + // Everything else (lowercase start) is an option key + $actualOptions[$key] = $value; + } + + return [$associations, $actualOptions]; + } } diff --git a/src/ORM/Attribute/CollectionOf.php b/src/ORM/Attribute/CollectionOf.php index 7c60dbff5df..418d025ad85 100644 --- a/src/ORM/Attribute/CollectionOf.php +++ b/src/ORM/Attribute/CollectionOf.php @@ -38,13 +38,13 @@ * ``` */ #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] -class CollectionOf +readonly class CollectionOf { /** * @param class-string $class The DTO class for collection elements */ public function __construct( - public string $class, + protected string $class, ) { } } diff --git a/src/ORM/Table.php b/src/ORM/Table.php index 9e9f02dd887..7a22e52d26c 100644 --- a/src/ORM/Table.php +++ b/src/ORM/Table.php @@ -127,14 +127,20 @@ * - `Model.afterSaveCommit` Fired after the transaction in which the save operation is * wrapped has been committed. It’s also triggered for non atomic saves where database * operations are implicitly committed. The event is triggered only for the primary - * table on which save() is directly called. The event is not triggered if a - * transaction is started before calling save. + * table on which save() is directly called. When called inside an outer transaction, + * the event is deferred until the outermost transaction commits. * * - `Model.beforeDelete` Fired before an entity is deleted. By stopping this * event you will abort the delete operation. * * - `Model.afterDelete` Fired after an entity has been deleted. * + * - `Model.afterDeleteCommit` Fired after the transaction in which the delete operation is + * wrapped has been committed. It's also triggered for non atomic deletes where database + * operations are implicitly committed. The event is triggered only for the primary + * table on which delete() is directly called. When called inside an outer transaction, + * the event is deferred until the outermost transaction commits. + * * ### Callbacks * * You can subscribe to the events listed above in your table classes by implementing the @@ -1658,6 +1664,13 @@ public function findOrCreate( if ($entity && $this->_transactionCommitted($options['atomic'], true)) { $this->dispatchEvent('Model.afterSaveCommit', compact('entity', 'options')); + } elseif ($entity && $this->getConnection()->inTransaction()) { + $this->getConnection()->afterCommit( + fn() => $this->dispatchEvent('Model.afterSaveCommit', [ + 'entity' => $entity, + 'options' => $options, + ]), + ); } return $entity; @@ -1976,15 +1989,35 @@ public function save( ); if ($success) { + $deferToCommit = $options['_primary'] + && $this->getConnection()->inTransaction(); + if ($this->_transactionCommitted($options['atomic'], $options['_primary'])) { $this->dispatchEvent('Model.afterSaveCommit', compact('entity', 'options')); + } elseif ($deferToCommit) { + $this->getConnection()->afterCommit( + fn() => $this->dispatchEvent('Model.afterSaveCommit', [ + 'entity' => $entity, + 'options' => $options, + ]), + ); } if ($options['atomic'] || $options['_primary']) { - if ($options['_cleanOnSuccess']) { - $entity->clean(); - $entity->setNew(false); + if ($deferToCommit) { + $this->getConnection()->afterCommit(function () use ($entity, $options): void { + if ($options['_cleanOnSuccess']) { + $entity->clean(); + $entity->setNew(false); + } + $entity->setSource($this->getRegistryAlias()); + }); + } else { + if ($options['_cleanOnSuccess']) { + $entity->clean(); + $entity->setNew(false); + } + $entity->setSource($this->getRegistryAlias()); } - $entity->setSource($this->getRegistryAlias()); } } @@ -2399,9 +2432,9 @@ protected function _saveMany( } }; + // afterSaveCommit is dispatched by individual save() calls via afterCommit() if ($this->_transactionCommitted($options['atomic'], $options['_primary'])) { foreach ($entities as $entity) { - $this->dispatchEvent('Model.afterSaveCommit', compact('entity', 'options')); if ($options['atomic'] || $options['_primary']) { $cleanupOnSuccess($entity); } @@ -2459,6 +2492,13 @@ public function delete(EntityInterface $entity, array $options = []): bool 'entity' => $entity, 'options' => $options, ]); + } elseif ($success && $options['_primary'] && $this->getConnection()->inTransaction()) { + $this->getConnection()->afterCommit( + fn() => $this->dispatchEvent('Model.afterDeleteCommit', [ + 'entity' => $entity, + 'options' => $options, + ]), + ); } return $success; @@ -2544,6 +2584,15 @@ protected function _deleteMany(iterable $entities, array $options = []): ?Entity 'options' => $options, ]); } + } elseif ($failed === null && $options['_primary'] && $this->getConnection()->inTransaction()) { + foreach ($entities as $entity) { + $this->getConnection()->afterCommit( + fn() => $this->dispatchEvent('Model.afterDeleteCommit', [ + 'entity' => $entity, + 'options' => $options, + ]), + ); + } } return $failed; diff --git a/src/ORM/composer.json b/src/ORM/composer.json index 7e70501870b..59682698cb9 100644 --- a/src/ORM/composer.json +++ b/src/ORM/composer.json @@ -24,17 +24,17 @@ }, "require": { "php": ">=8.2", - "cakephp/collection": "^5.3.0", - "cakephp/core": "^5.3.0", - "cakephp/datasource": "^5.3.0", - "cakephp/database": "^5.3.0", - "cakephp/event": "^5.3.0", - "cakephp/utility": "^5.3.0", - "cakephp/validation": "^5.3.0" + "cakephp/collection": "5.4.*@dev", + "cakephp/core": "5.4.*@dev", + "cakephp/datasource": "5.4.*@dev", + "cakephp/database": "5.4.*@dev", + "cakephp/event": "5.4.*@dev", + "cakephp/utility": "5.4.*@dev", + "cakephp/validation": "5.4.*@dev" }, "require-dev": { - "cakephp/cache": "^5.3.0", - "cakephp/i18n": "^5.3.0" + "cakephp/cache": "5.4.*@dev", + "cakephp/i18n": "5.4.*@dev" }, "autoload": { "psr-4": { diff --git a/src/TestSuite/TestCase.php b/src/TestSuite/TestCase.php index 368db63f82b..2ff08b47ab5 100644 --- a/src/TestSuite/TestCase.php +++ b/src/TestSuite/TestCase.php @@ -39,6 +39,7 @@ use Exception; use LogicException; use Mockery; +use Mockery\LegacyMockInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase as BaseTestCase; use ReflectionClass; @@ -1035,7 +1036,7 @@ protected function skipUnless($condition, $message = '') // phpcs:enable /** - * Mock a model, maintain fixtures and table association + * Mock a model with PHPUnit mocks, maintain fixtures and table association * * @param string $alias The model to get a mock for. * @param array $methods The list of methods to mock @@ -1105,6 +1106,48 @@ public function getMockForModel(string $alias, array $methods = [], array $optio return $mock; } + /** + * Mock a model with Mockery mocks, maintain fixtures and table association + * + * @template T of \Cake\ORM\Table + * @param string|class-string $alias The alias or the FQCN of the model to get a mock for. + * @param array $options The config data for the mock's constructor. + * @return (T|\Cake\ORM\Table)&\Mockery\LegacyMockInterface + */ + public function mockModel(string $alias, array $options = []): Table&LegacyMockInterface + { + $className = $this->_getTableClassName($alias, $options); + $connectionName = $className::defaultConnectionName(); + $connection = ConnectionManager::get($connectionName); + + $locator = $this->getTableLocator(); + + [, $baseClass] = pluginSplit($alias); + $options += ['alias' => $baseClass, 'connection' => $connection]; + $options += $locator->getConfig($alias); + + $mock = Mockery::mock(new $className($options))->makePartial(); + assert($mock instanceof Table); + + if (empty($options['entityClass']) && $mock->getEntityClass() === Entity::class) { + $parts = explode('\\', $className); + $entityAlias = Inflector::classify(Inflector::underscore(substr(array_pop($parts), 0, -5))); + $entityClass = implode('\\', array_slice($parts, 0, -1)) . '\\Entity\\' . $entityAlias; + if (class_exists($entityClass)) { + $mock->setEntityClass($entityClass); + } + } + + if (stripos($mock->getTable(), 'mock') === 0) { + $mock->setTable(Inflector::tableize($baseClass)); + } + + $locator->set($baseClass, $mock); + $locator->set($alias, $mock); + + return $mock; + } + /** * Gets the class name for the table. * diff --git a/src/Utility/Filesystem.php b/src/Utility/Filesystem.php index 31743161bb9..e53a978a29e 100644 --- a/src/Utility/Filesystem.php +++ b/src/Utility/Filesystem.php @@ -17,11 +17,11 @@ namespace Cake\Utility; use Cake\Core\Exception\CakeException; +use Cake\Utility\Fs\Iterator\HiddenFileFilterIterator; use CallbackFilterIterator; use Closure; use FilesystemIterator; use Iterator; -use RecursiveCallbackFilterIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RegexIterator; @@ -58,13 +58,19 @@ public function find(string $path, Closure|string|null $filter = null, ?int $fla $flags ??= FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS; + $directory = new FilesystemIterator($path, $flags); + // Apply filter if provided if ($filter === null) { return $directory; } - return $this->filterIterator($directory, $filter); + if (is_string($filter)) { + return new RegexIterator($directory, $filter); + } + + return new CallbackFilterIterator($directory, $filter); } /** @@ -82,40 +88,19 @@ public function findRecursive(string $path, Closure|string|null $filter = null, $flags ??= FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO | FilesystemIterator::SKIP_DOTS; - $directory = new RecursiveDirectoryIterator($path, $flags); - $dirFilter = new RecursiveCallbackFilterIterator( - $directory, - function (SplFileInfo $current) { - if (str_starts_with($current->getFilename(), '.') && $current->isDir()) { - return false; - } + $directory = new RecursiveDirectoryIterator($path, $flags); - return true; - }, - ); + // Filter out hidden directories + $filtered = new HiddenFileFilterIterator($directory); - $flatten = new RecursiveIteratorIterator( - $dirFilter, - RecursiveIteratorIterator::CHILD_FIRST, - ); + $iterator = new RecursiveIteratorIterator($filtered, RecursiveIteratorIterator::CHILD_FIRST); + // Apply custom filter if provided if ($filter === null) { - return $flatten; + return $iterator; } - return $this->filterIterator($flatten, $filter); - } - - /** - * Wrap iterator in additional filtering iterator. - * - * @param \Iterator $iterator Iterator - * @param \Closure|string $filter Regex string or callback. - * @return \Iterator - */ - protected function filterIterator(Iterator $iterator, Closure|string $filter): Iterator - { if (is_string($filter)) { return new RegexIterator($iterator, $filter); } diff --git a/src/Utility/Fs/Enum/DepthOperator.php b/src/Utility/Fs/Enum/DepthOperator.php new file mode 100644 index 00000000000..7c2addbae82 --- /dev/null +++ b/src/Utility/Fs/Enum/DepthOperator.php @@ -0,0 +1,55 @@ +) + */ + case GREATER_THAN = '>'; + + /** + * Less than or equal to (<=) + */ + case LESS_THAN_OR_EQUAL = '<='; + + /** + * Greater than or equal to (>=) + */ + case GREATER_THAN_OR_EQUAL = '>='; +} diff --git a/src/Utility/Fs/Enum/FinderMode.php b/src/Utility/Fs/Enum/FinderMode.php new file mode 100644 index 00000000000..1fbf5a9b7fc --- /dev/null +++ b/src/Utility/Fs/Enum/FinderMode.php @@ -0,0 +1,40 @@ +in('src') + * ->name('*.php') + * ->exclude('vendor') + * ->files(); + * + * foreach ($files as $file) { + * echo $file->getPathname(); + * } + * + * // Find directories + * $directories = (new Finder()) + * ->in('src') + * ->exclude('vendor') + * ->directories(); + * + * // Find both files and directories + * $all = (new Finder()) + * ->in('src') + * ->all(); + * ``` + */ +class Finder +{ + /** + * Base paths to search in + * + * @var array + */ + protected array $paths = []; + + /** + * Name patterns to match + * + * @var array + */ + protected array $names = []; + + /** + * Name patterns to exclude + * + * @var array + */ + protected array $notNames = []; + + /** + * Directories to exclude + * + * @var array + */ + protected array $exclude = []; + + /** + * Path patterns to include + * + * @var array + */ + protected array $pathPatterns = []; + + /** + * Path patterns to exclude + * + * @var array + */ + protected array $notPathPatterns = []; + + /** + * Glob patterns for full path matching + * + * @var array + */ + protected array $globPatterns = []; + + /** + * Depth conditions + * + * @var array + */ + protected array $depths = []; + + /** + * Whether to ignore hidden files + * + * @var bool + */ + protected bool $ignoreHiddenFiles = true; + + /** + * Whether to search recursively + * + * @var bool + */ + protected bool $recursive = true; + + /** + * The iteration mode (files, directories, or all) + * + * @var \Cake\Utility\Fs\Enum\FinderMode|null + */ + protected ?FinderMode $mode = null; + + /** + * Custom filter callbacks + * + * @var array<\Closure(\SplFileInfo, string): bool> + */ + protected array $filters = []; + + /** + * Add a path to search in. + * + * @param string $path The directory path + * @return $this + */ + public function in(string $path) + { + $this->paths[] = $path; + + return $this; + } + + /** + * Add a name pattern to match. + * + * @param string $pattern Glob pattern (e.g., '*.php') + * @return $this + */ + public function name(string $pattern) + { + $this->names[] = $pattern; + + return $this; + } + + /** + * Add a name pattern that must not be matched. + * + * @param string $pattern Glob pattern to exclude (e.g., '*.rb', '*Test.php') + * @return $this + */ + public function notName(string $pattern) + { + $this->notNames[] = $pattern; + + return $this; + } + + /** + * Exclude a directory from the search. + * + * @param string $directory Directory name to exclude + * @return $this + */ + public function exclude(string $directory) + { + $this->exclude[] = $directory; + + return $this; + } + + /** + * Add a path pattern that must be matched. + * + * @param string $pattern Path pattern (e.g., 'Controller') + * @return $this + */ + public function path(string $pattern) + { + $this->pathPatterns[] = $pattern; + + return $this; + } + + /** + * Add a path pattern that must not be matched. + * + * @param string $pattern Path pattern to exclude + * @return $this + */ + public function notPath(string $pattern) + { + $this->notPathPatterns[] = $pattern; + + return $this; + } + + /** + * Add a glob pattern for full path matching. + * + * Supports wildcards like `src/**\/*.php` for recursive matching. + * + * @param string $pattern Glob pattern (e.g., 'src/**\/*.php', 'tests/**\/*Test.php') + * @return $this + */ + public function pattern(string $pattern) + { + $this->globPatterns[] = $pattern; + + return $this; + } + + /** + * Add a custom filter callback. + * + * The callback receives the SplFileInfo object and relative path, + * and should return true to include the file. + * + * Example: + * ```php + * $finder->filter(fn(\SplFileInfo $file) => $file->getSize() > 1024); + * ``` + * + * @param \Closure(\SplFileInfo, string): bool $callback Filter callback + * @return $this + */ + public function filter(Closure $callback) + { + $this->filters[] = $callback; + + return $this; + } + + /** + * Add a depth condition. + * + * @param int $level The depth level (0 = top-level directory) + * @param \Cake\Utility\Fs\Enum\DepthOperator $operator The comparison operator (default: EQUAL) + * @return $this + */ + public function depth(int $level, DepthOperator $operator = DepthOperator::EQUAL) + { + $this->depths[] = [$operator, $level]; + + return $this; + } + + /** + * Set whether to ignore hidden files and directories. + * + * @param bool $ignore Whether to ignore hidden files + * @return $this + */ + public function ignoreHiddenFiles(bool $ignore = true) + { + $this->ignoreHiddenFiles = $ignore; + + return $this; + } + + /** Set whether to search recursively into subdirectories. + * + * @param bool $recursive Whether to search recursively (default: true) + * @return $this + */ + public function recursive(bool $recursive = true) + { + $this->recursive = $recursive; + + return $this; + } + + /** + * Get files matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + public function files(): Iterator + { + $this->mode = FinderMode::FILES; + + return $this->iterate(); + } + + /** + * Get directories matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + public function directories(): Iterator + { + $this->mode = FinderMode::DIRECTORIES; + + return $this->iterate(); + } + + /** + * Get both files and directories matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + public function all(): Iterator + { + $this->mode = FinderMode::ALL; + + return $this->iterate(); + } + + /** + * Iterate over items matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + protected function iterate(): Iterator + { + // Combine results from all paths + if (count($this->paths) === 1) { + return $this->buildIterator($this->paths[0]); + } + + // Multiple paths - use AppendIterator + $append = new AppendIterator(); + foreach ($this->paths as $path) { + $append->append($this->buildIterator($path)); + } + + return $append; + } + + /** + * Build an iterator chain with all configured filters. + * + * @param string $path The directory path + * @return \Iterator<\SplFileInfo> + */ + protected function buildIterator(string $path): Iterator + { + $flags = FilesystemIterator::KEY_AS_PATHNAME + | FilesystemIterator::CURRENT_AS_FILEINFO + | FilesystemIterator::SKIP_DOTS; + + $directory = new RecursiveDirectoryIterator($path, $flags); + + // Apply hidden file filtering + if ($this->ignoreHiddenFiles) { + $directory = new HiddenFileFilterIterator($directory); + } + + // Apply directory exclusions + if ($this->exclude !== []) { + $directory = new ExcludeDirectoryFilterIterator($directory, $this->exclude); + } + + // Apply path pattern exclusions during recursion + if ($this->notPathPatterns !== []) { + $directory = new ContainsPathFilterIterator($directory, $this->notPathPatterns, negate: true); + } + + // Apply path pattern inclusions during recursion (non-regex patterns only) + if ($this->pathPatterns !== []) { + $directory = new ContainsPathFilterIterator($directory, $this->pathPatterns); + } + + // Use SELF_FIRST when looking for directories to include them in iteration + // Use LEAVES_ONLY when looking for files only for optimization + $iteratorMode = $this->mode === FinderMode::FILES + ? RecursiveIteratorIterator::LEAVES_ONLY + : RecursiveIteratorIterator::SELF_FIRST; + + $iterator = new RecursiveIteratorIterator($directory, $iteratorMode); + + // Apply file type filtering + if ($this->mode !== null && $this->mode !== FinderMode::ALL) { + $iterator = new FileTypeFilterIterator($iterator, $this->mode); + } + + // Apply filename filtering + if ($this->names !== []) { + $iterator = new FilenameFilterIterator($iterator, $this->names); + } + if ($this->notNames !== []) { + $iterator = new FilenameFilterIterator($iterator, $this->notNames, negate: true); + } + + // Apply depth filtering (handles non-recursive mode when recursive=false) + if (!$this->recursive) { + $iterator = new DepthFilterIterator($iterator, DepthOperator::EQUAL, 0); + } + foreach ($this->depths as [$operator, $level]) { + $iterator = new DepthFilterIterator($iterator, $operator, $level); + } + + // Apply glob pattern filtering + if ($this->globPatterns !== []) { + $iterator = new GlobFilterIterator($iterator, $this->globPatterns, $path); + } + + // Apply custom filters + foreach ($this->filters as $callback) { + $iterator = new CallbackFilterIterator($iterator, $callback, $path); + } + + return $iterator; + } +} diff --git a/src/Utility/Fs/Iterator/CallbackFilterIterator.php b/src/Utility/Fs/Iterator/CallbackFilterIterator.php new file mode 100644 index 00000000000..f9a7a6889d7 --- /dev/null +++ b/src/Utility/Fs/Iterator/CallbackFilterIterator.php @@ -0,0 +1,60 @@ +> + */ +final class CallbackFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param \Closure(\SplFileInfo, string): bool $callback Filter callback + * @param string $basePath Base path for calculating relative paths + */ + public function __construct( + Iterator $iterator, + protected Closure $callback, + protected readonly string $basePath, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + /** @var \SplFileInfo $file */ + $file = $this->current(); + + // Calculate relative path + $relativePath = Path::makeRelative( + Path::normalize($file->getPathname()), + Path::normalize($this->basePath), + ); + + return ($this->callback)($file, $relativePath); + } +} diff --git a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php new file mode 100644 index 00000000000..2c89004aac5 --- /dev/null +++ b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php @@ -0,0 +1,139 @@ + + */ + protected array $stringPatterns; + + /** + * Regex patterns (pattern matching, normalized) + * + * @var array + */ + protected array $regexPatterns; + + /** + * @param \RecursiveIterator $iterator The iterator to filter + * @param array $patterns Path patterns to match (string or regex) + * @param bool $negate When true, inverts the filter (excludes matching paths) + */ + public function __construct( + RecursiveIterator $iterator, + array $patterns, + protected readonly bool $negate = false, + ) { + parent::__construct($iterator); + + // Separate regex patterns from string patterns + $regexPatterns = []; + $stringPatterns = []; + + foreach ($patterns as $pattern) { + if (preg_match('/^[\/#~]/', $pattern)) { + $regexPatterns[] = $pattern; + } else { + $stringPatterns[] = $pattern; + } + } + + // Normalize string patterns for cross-platform compatibility + $this->stringPatterns = array_map(fn(string $p) => Path::normalize($p), $stringPatterns); + + // Normalize regex patterns (normalize the paths they'll match against) + $this->regexPatterns = $regexPatterns; + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $current = $this->current(); + + // Always accept directories to allow traversal + if ($current->isDir()) { + return true; + } + + // If no patterns at all, accept everything (no-op) + if ($this->stringPatterns === [] && $this->regexPatterns === []) { + return true; + } + + // For files, check if path matches patterns + $path = Path::normalize($current->getPathname()); + $matches = false; + + // Check string patterns (substring matching) + foreach ($this->stringPatterns as $pattern) { + if (str_contains($path, $pattern)) { + $matches = true; + break; + } + } + + // Check regex patterns if no string match found + if (!$matches) { + foreach ($this->regexPatterns as $pattern) { + if (preg_match($pattern, $path)) { + $matches = true; + break; + } + } + } + + return $this->negate ? !$matches : $matches; + } + + /** + * @inheritDoc + */ + public function getChildren(): self + { + /** @var \RecursiveIterator $inner */ + $inner = $this->getInnerIterator(); + + // Pass all original patterns through (constructor will separate them again) + $allPatterns = array_merge($this->stringPatterns, $this->regexPatterns); + + return new self($inner->getChildren(), $allPatterns, $this->negate); + } +} diff --git a/src/Utility/Fs/Iterator/DepthFilterIterator.php b/src/Utility/Fs/Iterator/DepthFilterIterator.php new file mode 100644 index 00000000000..4c1f796bb42 --- /dev/null +++ b/src/Utility/Fs/Iterator/DepthFilterIterator.php @@ -0,0 +1,87 @@ += value + * + * @extends \FilterIterator> + */ +final class DepthFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter (typically RecursiveIteratorIterator) + * @param \Cake\Utility\Fs\Enum\DepthOperator $operator Comparison operator + * @param int $value Depth value to compare against + */ + public function __construct( + Iterator $iterator, + protected readonly DepthOperator $operator, + protected readonly int $value, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $inner = $this->getInnerIterator(); + + // If the inner iterator is a RecursiveIteratorIterator, use its getDepth() + if ($inner instanceof RecursiveIteratorIterator) { + $depth = $inner->getDepth(); + } else { + // For other iterators wrapped in callbacks/filters, we need to unwrap + // until we find the RecursiveIteratorIterator + $current = $inner; + while ($current instanceof FilterIterator) { + $current = $current->getInnerIterator(); + } + + if ($current instanceof RecursiveIteratorIterator) { + $depth = $current->getDepth(); + } else { + // Fallback: can't determine depth, accept everything + return true; + } + } + + return match ($this->operator) { + DepthOperator::EQUAL => $depth === $this->value, + DepthOperator::NOT_EQUAL => $depth !== $this->value, + DepthOperator::LESS_THAN => $depth < $this->value, + DepthOperator::LESS_THAN_OR_EQUAL => $depth <= $this->value, + DepthOperator::GREATER_THAN => $depth > $this->value, + DepthOperator::GREATER_THAN_OR_EQUAL => $depth >= $this->value, + }; + } +} diff --git a/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php new file mode 100644 index 00000000000..d7d7cb4f540 --- /dev/null +++ b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php @@ -0,0 +1,79 @@ + $iterator The iterator to filter + * @param array $excludeDirs Array of directory names to exclude + */ + public function __construct( + RecursiveIterator $iterator, + protected readonly array $excludeDirs, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + /** @var \SplFileInfo $current */ + $current = $this->current(); + + // Always accept files + if (!$current->isDir()) { + return true; + } + + // Check if directory name matches any excluded names + $filename = $current->getFilename(); + foreach ($this->excludeDirs as $excluded) { + if ($filename === $excluded) { + return false; + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function getChildren(): self + { + /** @var \RecursiveIterator $inner */ + $inner = $this->getInnerIterator(); + + return new self($inner->getChildren(), $this->excludeDirs); + } +} diff --git a/src/Utility/Fs/Iterator/FileTypeFilterIterator.php b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php new file mode 100644 index 00000000000..1802675173b --- /dev/null +++ b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php @@ -0,0 +1,56 @@ +> + */ +class FileTypeFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param \Cake\Utility\Fs\Enum\FinderMode $mode The mode (FILES, DIRECTORIES, or ALL) + */ + public function __construct( + Iterator $iterator, + protected readonly FinderMode $mode, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + /** @var \SplFileInfo $current */ + $current = $this->current(); + + return match ($this->mode) { + FinderMode::FILES => $current->isFile(), + FinderMode::DIRECTORIES => $current->isDir(), + FinderMode::ALL => true, + }; + } +} diff --git a/src/Utility/Fs/Iterator/FilenameFilterIterator.php b/src/Utility/Fs/Iterator/FilenameFilterIterator.php new file mode 100644 index 00000000000..cd0c0450b09 --- /dev/null +++ b/src/Utility/Fs/Iterator/FilenameFilterIterator.php @@ -0,0 +1,69 @@ +> + */ +final class FilenameFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param array $patterns Glob patterns to match against + * @param bool $negate When true, inverts the filter (excludes matching files) + */ + public function __construct( + Iterator $iterator, + protected readonly array $patterns, + protected readonly bool $negate = false, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $filename = $this->current()->getFilename(); + + $matches = false; + foreach ($this->patterns as $pattern) { + if (Path::matches($pattern, $filename)) { + $matches = true; + break; + } + } + + return $this->negate ? !$matches : $matches; + } +} diff --git a/src/Utility/Fs/Iterator/GlobFilterIterator.php b/src/Utility/Fs/Iterator/GlobFilterIterator.php new file mode 100644 index 00000000000..61d3c59f2f7 --- /dev/null +++ b/src/Utility/Fs/Iterator/GlobFilterIterator.php @@ -0,0 +1,66 @@ +> + */ +final class GlobFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param array $patterns Glob patterns to match + * @param string $basePath Base path to calculate relative paths from + */ + public function __construct( + Iterator $iterator, + protected readonly array $patterns, + protected readonly string $basePath, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $relativePath = Path::makeRelative( + $this->current()->getPathname(), + $this->basePath, + ); + + foreach ($this->patterns as $pattern) { + if (Path::matches($pattern, $relativePath)) { + return true; + } + } + + return false; + } +} diff --git a/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php b/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php new file mode 100644 index 00000000000..3d605204d39 --- /dev/null +++ b/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php @@ -0,0 +1,49 @@ +current(); + + return !str_starts_with($current->getFilename(), '.'); + } + + /** + * @inheritDoc + */ + public function getChildren(): self + { + /** @var \RecursiveIterator $inner */ + $inner = $this->getInnerIterator(); + + return new self($inner->getChildren()); + } +} diff --git a/src/Utility/Fs/Path.php b/src/Utility/Fs/Path.php new file mode 100644 index 00000000000..6e60a2b76cd --- /dev/null +++ b/src/Utility/Fs/Path.php @@ -0,0 +1,148 @@ +encrypt($plain, $key); - $hmac = hash_hmac('sha256', $ciphertext, $key); + $ciphertext = $crypto->encrypt($plain, $encryptionKey); + $hmac = hash_hmac('sha256', $ciphertext, $hmacKey); return $hmac . $ciphertext; } @@ -228,6 +230,35 @@ protected static function _checkKey(string $key, string $method): void } } + /** + * Generate a key pair of encryption and authentication tokens. + * + * Encapsulates the two key generation implementations we support. + * The previous implementation has a keyspace reduction weakness. + * + * It is recommended to enable `Security.encryptWithRawKey` in new applications, + * to take advantage of longer keys that are longer and have derived encryption + * and authentication keys. + * + * @param string $key The bare key to use. + * @param string $hmacSalt The hmac salt to use. + * @return array{string, string} A list of $encryption, $authentication keys intended for encrypt() and decrypt(). + */ + protected static function makeEncryptionKeys(string $key, string $hmacSalt): array + { + if (Configure::read('Security.encryptWithRawKey') === true) { + $encryption = hash_hkdf('sha256', $key, 32, 'encryption', $hmacSalt); + $authentication = hash_hkdf('sha256', $key, 32, 'authentication', $hmacSalt); + + return [$encryption, $authentication]; + } + + $hashKey = mb_substr(hash('sha256', $key . $hmacSalt), 0, 32, '8bit'); + + // The old implementation both keys were the same. + return [$hashKey, $hashKey]; + } + /** * Decrypt a value using AES-256. * @@ -247,21 +278,21 @@ public static function decrypt(string $cipher, string $key, ?string $hmacSalt = $hmacSalt ??= static::getSalt(); // Generate the encryption and hmac key. - $key = mb_substr(hash('sha256', $key . $hmacSalt), 0, 32, '8bit'); + [$encryptionKey, $hmacKey] = static::makeEncryptionKeys($key, $hmacSalt); // Split out hmac for comparison $macSize = 64; $hmac = mb_substr($cipher, 0, $macSize, '8bit'); $cipher = mb_substr($cipher, $macSize, null, '8bit'); - $compareHmac = hash_hmac('sha256', $cipher, $key); + $compareHmac = hash_hmac('sha256', $cipher, $hmacKey); if (!static::constantEquals($hmac, $compareHmac)) { return null; } $crypto = static::engine(); - return $crypto->decrypt($cipher, $key); + return $crypto->decrypt($cipher, $encryptionKey); } /** diff --git a/src/Utility/Text.php b/src/Utility/Text.php index 541b2d34d12..8ff8ebbff9d 100644 --- a/src/Utility/Text.php +++ b/src/Utility/Text.php @@ -1190,4 +1190,55 @@ public static function slug(string $string, array|string $options = []): string return (string)preg_replace(array_keys($map), $map, $string); } + + /** + * Masks a portion of a string with a repeated character. + * Replaces characters from $offset to $offset + $length with $maskCharacter. + * + * If $length is null, it will mask until the end of the string. + * + * Negative $offset value will count from the end of the string. If the computed $offset is still less than 0, it is clamped to 0. + * An $offset at or beyond the string length returns the original string unchanged. + * + * @param string $string The input string. + * @param int $offset Start position of the mask. Negative values count from the end. + * @param int|null $length Number of characters to mask. Null masks from $offset to end of string. + * @param string $maskCharacter The single Unicode code point character to use as the mask. Defaults to '*'. + * @throws \InvalidArgumentException If $maskCharacter is not exactly a single character. + * @return string + */ + public static function mask(string $string, int $offset = 0, ?int $length = null, string $maskCharacter = '*'): string + { + if (mb_strlen($maskCharacter) !== 1) { + throw new InvalidArgumentException('Mask character must be a single character.'); + } + + if ($string === '') { + return $string; + } + + $stringLength = mb_strlen($string); + + if ($offset < 0) { + $offset = max(0, $stringLength + $offset); + } + + if ($offset >= $stringLength) { + return $string; + } + + if ($length !== null && $length <= 0) { + return $string; + } + + $length = $length === null + ? $stringLength - $offset + : min($length, $stringLength - $offset); + + $start = mb_substr($string, 0, $offset); + $mask = str_repeat($maskCharacter, $length); + $end = mb_substr($string, $offset + $length); + + return $start . $mask . $end; + } } diff --git a/src/Utility/composer.json b/src/Utility/composer.json index cb4b0ec9af1..b9d56e6e2f8 100644 --- a/src/Utility/composer.json +++ b/src/Utility/composer.json @@ -26,7 +26,7 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0" + "cakephp/core": "5.4.*@dev" }, "autoload": { "psr-4": { diff --git a/src/Validation/composer.json b/src/Validation/composer.json index e888bac34ae..2e91b8f7e50 100644 --- a/src/Validation/composer.json +++ b/src/Validation/composer.json @@ -23,12 +23,12 @@ }, "require": { "php": ">=8.2", - "cakephp/core": "^5.3.0", - "cakephp/utility": "^5.3.0", + "cakephp/core": "5.4.*@dev", + "cakephp/utility": "5.4.*@dev", "psr/http-message": "^1.1 || ^2.0" }, "require-dev": { - "cakephp/i18n": "^5.3.0" + "cakephp/i18n": "5.4.*@dev" }, "autoload": { "psr-4": { diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index c9bc5c57fa6..6980511d01d 100644 --- a/src/View/Helper/FormHelper.php +++ b/src/View/Helper/FormHelper.php @@ -217,7 +217,7 @@ class FormHelper extends Helper * * @var string|null */ - public ?string $requestType = null; + protected ?string $requestType = null; /** * Locator for input widgets. @@ -816,6 +816,7 @@ public function error(string $field, array|string|null $text = null, array $opti return $this->formatTemplate('error', [ 'content' => $error, 'id' => $this->_domId($field) . '-error', + 'inputId' => $this->_domId($field), ]); } @@ -1188,6 +1189,7 @@ public function control(string $fieldName, array $options = []): string 'errorSuffix' => $errorSuffix, 'label' => $label, 'options' => $options, + 'inputId' => $this->_domId($fieldName), ]); if ($newTemplates) { @@ -1234,6 +1236,7 @@ protected function _inputContainerTemplate(array $options): string return $this->formatTemplate($inputContainerTemplate, [ 'content' => $options['content'], 'error' => $options['error'], + 'inputId' => $options['inputId'] ?? '', 'label' => $options['label'] ?? '', 'required' => $options['options']['required'] ? ' ' . $this->templater()->get('requiredClass') : '', 'type' => $options['options']['type'], @@ -1393,7 +1396,7 @@ protected function _optionsOptions(string $fieldName, array $options): array * @param class-string<\BackedEnum> $enumClass Enum class name. * @return array */ - protected function enumOptions(string $enumClass): array + public function enumOptions(string $enumClass): array { assert(is_subclass_of($enumClass, BackedEnum::class)); @@ -2442,14 +2445,7 @@ protected function _initInputField(string $field, array $options = []): array } if (!isset($options['name'])) { - $endsWithBrackets = ''; - if (str_ends_with($field, '[]')) { - $field = substr($field, 0, -2); - $endsWithBrackets = '[]'; - } - $parts = explode('.', $field); - $first = array_shift($parts); - $options['name'] = $first . ($parts !== [] ? '[' . implode('][', $parts) . ']' : '') . $endsWithBrackets; + $options['name'] = $this->_fieldName($field); } if (isset($options['value']) && !isset($options['val'])) { @@ -2493,6 +2489,29 @@ protected function _initInputField(string $field, array $options = []): array return $options; } + /** + * Generate the HTML name attribute value from a field name. + * + * Converts dot notation field names to bracket notation used in HTML forms. + * For example, "User.email" becomes "User[email]" and "User.address.city" + * becomes "User[address][city]". + * + * @param string $field Field name in dot notation. + * @return string HTML name attribute value. + */ + protected function _fieldName(string $field): string + { + $endsWithBrackets = ''; + if (str_ends_with($field, '[]')) { + $field = substr($field, 0, -2); + $endsWithBrackets = '[]'; + } + $parts = explode('.', $field); + $first = array_shift($parts); + + return $first . ($parts !== [] ? '[' . implode('][', $parts) . ']' : '') . $endsWithBrackets; + } + /** * Determine if a field is disabled. * diff --git a/src/View/Widget/SelectBoxWidget.php b/src/View/Widget/SelectBoxWidget.php index 4c113f15f2d..383c011b4de 100644 --- a/src/View/Widget/SelectBoxWidget.php +++ b/src/View/Widget/SelectBoxWidget.php @@ -16,7 +16,6 @@ */ namespace Cake\View\Widget; -use ArrayAccess; use Cake\View\Form\ContextInterface; use Traversable; use function Cake\Core\h; @@ -198,7 +197,7 @@ protected function _emptyValue(array|string|bool $value): array * Render the contents of an optgroup element. * * @param string $label The optgroup label text - * @param \ArrayAccess|array $optgroup The optgroup data. + * @param iterable $optgroup The optgroup data. * @param array|null $disabled The options to disable. * @param mixed $selected The options to select. * @param array $templateVars Additional template variables. @@ -207,7 +206,7 @@ protected function _emptyValue(array|string|bool $value): array */ protected function _renderOptgroup( string $label, - ArrayAccess|array $optgroup, + iterable $optgroup, ?array $disabled, mixed $selected, array $templateVars, @@ -215,10 +214,10 @@ protected function _renderOptgroup( ): string { $opts = $optgroup; $attrs = []; - if (isset($optgroup['options'], $optgroup['text'])) { + if (is_array($optgroup) && isset($optgroup['options'], $optgroup['text'])) { $opts = $optgroup['options']; $label = $optgroup['text']; - $attrs = (array)$optgroup; + $attrs = $optgroup; } $groupOptions = $this->_renderOptions($opts, $disabled, $selected, $templateVars, $escape); @@ -268,7 +267,7 @@ protected function _renderOptions( ) ) ) { - /** @var \ArrayAccess|array $val */ + /** @var iterable $val */ $out[] = $this->_renderOptgroup((string)$key, $val, $disabled, $selected, $templateVars, $escape); continue; } diff --git a/tests/TestCase/Collection/CollectionTest.php b/tests/TestCase/Collection/CollectionTest.php index 9ed8e5ccfd5..d42abcc8d54 100644 --- a/tests/TestCase/Collection/CollectionTest.php +++ b/tests/TestCase/Collection/CollectionTest.php @@ -2822,4 +2822,276 @@ public function testExtendedCollectionNoInfiniteLoop(): void $this->assertSame(3, $count); } + + /** + * Tests the keys() method returns collection of keys + */ + public function testKeys(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + $keys = $collection->keys()->toList(); + + $this->assertSame(['a', 'b', 'c'], $keys); + } + + /** + * Tests keys() with numeric keys + */ + public function testKeysNumeric(): void + { + $items = [10 => 'a', 20 => 'b', 30 => 'c']; + $collection = new Collection($items); + $keys = $collection->keys()->toList(); + + $this->assertSame([10, 20, 30], $keys); + } + + /** + * Tests keys() on empty collection + */ + public function testKeysEmpty(): void + { + $collection = new Collection([]); + $keys = $collection->keys()->toList(); + + $this->assertSame([], $keys); + } + + /** + * Tests the values() method returns re-indexed collection + */ + public function testValues(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + $values = $collection->values()->toArray(); + + $this->assertSame([0 => 1, 1 => 2, 2 => 3], $values); + } + + /** + * Tests values() maintains order + */ + public function testValuesOrder(): void + { + $items = ['z' => 3, 'a' => 1, 'm' => 2]; + $collection = new Collection($items); + $values = $collection->values()->toList(); + + $this->assertSame([3, 1, 2], $values); + } + + /** + * Tests values() on empty collection + */ + public function testValuesEmpty(): void + { + $collection = new Collection([]); + $values = $collection->values()->toList(); + + $this->assertSame([], $values); + } + + /** + * Tests the implode() method + */ + public function testImplode(): void + { + $items = ['a', 'b', 'c']; + $collection = new Collection($items); + $result = $collection->implode(', '); + + $this->assertSame('a, b, c', $result); + } + + /** + * Tests implode() with path extraction + */ + public function testImplodeWithPath(): void + { + $items = [ + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => 'baz'], + ]; + $collection = new Collection($items); + $result = $collection->implode(', ', 'name'); + + $this->assertSame('foo, bar, baz', $result); + } + + /** + * Tests implode() with nested path + */ + public function testImplodeWithNestedPath(): void + { + $items = [ + ['user' => ['name' => 'foo']], + ['user' => ['name' => 'bar']], + ]; + $collection = new Collection($items); + $result = $collection->implode(' - ', 'user.name'); + + $this->assertSame('foo - bar', $result); + } + + /** + * Tests implode() with callable + */ + public function testImplodeWithCallable(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + $result = $collection->implode(', ', fn($v) => $v * 2); + + $this->assertSame('2, 4, 6', $result); + } + + /** + * Tests implode() with empty collection + */ + public function testImplodeEmpty(): void + { + $collection = new Collection([]); + $result = $collection->implode(', '); + + $this->assertSame('', $result); + } + + /** + * Tests the when() method applies callback when condition is truthy + */ + public function testWhenTruthy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->when(true, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([3, 4, 5], $result); + } + + /** + * Tests when() does not apply callback when condition is falsy + */ + public function testWhenFalsy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->when(false, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([1, 2, 3, 4, 5], $result); + } + + /** + * Tests when() passes condition value to callback + */ + public function testWhenPassesCondition(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->when(3, function ($collection, $threshold) { + return $collection->filter(fn($v) => $v > $threshold); + })->toList(); + + $this->assertSame([4, 5], $result); + } + + /** + * Tests when() with zero as falsy condition + */ + public function testWhenZero(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + + $result = $collection->when(0, function ($collection) { + return $collection->filter(fn($v) => $v > 1); + })->toList(); + + $this->assertSame([1, 2, 3], $result); + } + + /** + * Tests the unless() method applies callback when condition is falsy + */ + public function testUnlessFalsy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->unless(false, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([3, 4, 5], $result); + } + + /** + * Tests unless() does not apply callback when condition is truthy + */ + public function testUnlessTruthy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->unless(true, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([1, 2, 3, 4, 5], $result); + } + + /** + * Tests unless() with null as falsy condition + */ + public function testUnlessNull(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + + $result = $collection->unless(null, function ($collection) { + return $collection->filter(fn($v) => $v > 1); + })->toList(); + + $this->assertSame([2, 3], $result); + } + + /** + * Tests unless() with empty string as falsy condition + */ + public function testUnlessEmptyString(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + + $result = $collection->unless('', function ($collection) { + return $collection->filter(fn($v) => $v > 1); + })->toList(); + + $this->assertSame([2, 3], $result); + } + + /** + * Tests chaining when() and unless() together + */ + public function testWhenUnlessChaining(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection + ->when(true, fn($c) => $c->filter(fn($v) => $v > 1)) + ->unless(false, fn($c) => $c->filter(fn($v) => $v < 5)) + ->toList(); + + $this->assertSame([2, 3, 4], $result); + } } diff --git a/tests/TestCase/Command/CompletionCommandTest.php b/tests/TestCase/Command/CompletionCommandTest.php index 38a842c50ef..9156bca234d 100644 --- a/tests/TestCase/Command/CompletionCommandTest.php +++ b/tests/TestCase/Command/CompletionCommandTest.php @@ -80,7 +80,6 @@ public function testCommands(): void 'unique', 'welcome', 'cache', - 'help', 'i18n', 'plugin', 'routes', diff --git a/tests/TestCase/Command/PluginListCommandTest.php b/tests/TestCase/Command/PluginListCommandTest.php index 1530c0eb20d..ae9f6a66c5b 100644 --- a/tests/TestCase/Command/PluginListCommandTest.php +++ b/tests/TestCase/Command/PluginListCommandTest.php @@ -17,7 +17,9 @@ use Cake\Console\CommandInterface; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Configure; use Cake\Core\Exception\MissingPluginException; +use Cake\Core\PluginConfig; use Cake\TestSuite\TestCase; /** @@ -55,6 +57,8 @@ protected function setUp(): void protected function tearDown(): void { parent::tearDown(); + Configure::delete('plugins'); + PluginConfig::clearCache(); if (file_exists($this->pluginsListPath)) { $this->deletePhpFile($this->pluginsListPath); } diff --git a/tests/TestCase/Console/BaseCommandTest.php b/tests/TestCase/Console/BaseCommandTest.php new file mode 100644 index 00000000000..2a8e8c676e5 --- /dev/null +++ b/tests/TestCase/Console/BaseCommandTest.php @@ -0,0 +1,212 @@ +args is available inside execute() + */ + public function testRunHydratesArgs(): void + { + $command = new class extends Command { + public ?Arguments $capturedArgs = null; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->capturedArgs = $this->args; + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertInstanceOf(Arguments::class, $command->capturedArgs); + } + + /** + * Test that $this->io is available inside execute() + */ + public function testRunHydratesIo(): void + { + $command = new class extends Command { + public ?ConsoleIo $capturedIo = null; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->capturedIo = $this->io; + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertSame($io, $command->capturedIo); + } + + /** + * Test that $this->args matches the Arguments passed to execute() + */ + public function testRunHydratedArgsMatchExecuteArgs(): void + { + $command = new class extends Command { + public bool $argsMatch = false; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->argsMatch = ($this->args === $args); + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->argsMatch); + } + + /** + * Test that $this->args and $this->io are hydrated before execute(), + * so traits/parent classes can rely on them without manual assignment. + */ + public function testRunHydratesPropertiesBeforeExecute(): void + { + $command = new class extends Command { + public bool $propsAvailable = false; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->propsAvailable = isset($this->args) && isset($this->io); + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->propsAvailable); + } + + /** + * Test that $this->io is accessible in initialize() + */ + public function testInitializeCanAccessIo(): void + { + $command = new class extends Command { + public bool $ioAccessible = false; + + public function initialize(): void + { + $this->ioAccessible = isset($this->io); + } + + public function execute(Arguments $args, ConsoleIo $io): int + { + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->ioAccessible); + } + + /** + * Test that $this->args is accessible in initialize() + */ + public function testInitializeCanAccessArgs(): void + { + $command = new class extends Command { + public bool $argsAccessible = false; + + public function initialize(): void + { + $this->argsAccessible = isset($this->args); + } + + public function execute(Arguments $args, ConsoleIo $io): int + { + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->argsAccessible); + } + + /** + * Test that hydrated args contain the parsed arguments from the command line. + */ + public function testRunHydratedArgsContainParsedValues(): void + { + $command = new class extends Command { + public ?string $capturedName = null; + + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->addArgument('name', ['required' => true]); + + return $parser; + } + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->capturedName = $this->args->getArgument('name'); + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run(['Alice'], $io); + + $this->assertSame('Alice', $command->capturedName); + } +} diff --git a/tests/TestCase/Console/Command/HelpCommandTest.php b/tests/TestCase/Console/Command/HelpCommandTest.php index a95760a25a9..6247af66120 100644 --- a/tests/TestCase/Console/Command/HelpCommandTest.php +++ b/tests/TestCase/Console/Command/HelpCommandTest.php @@ -18,6 +18,7 @@ use Cake\Console\CommandInterface; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Configure; use Cake\TestSuite\TestCase; /** @@ -53,6 +54,7 @@ public function testMainVerbose(): void { $this->exec('help -v'); $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('CakePHP:', 'header should appear in verbose mode'); $this->assertCommandListVerbose(); } @@ -63,22 +65,41 @@ public function testMainCompact(): void { $this->exec('help'); $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('CakePHP:', 'header should appear in compact mode'); $this->assertOutputContains('Available Commands:', 'single commands header'); $this->assertOutputContains('routes:', 'routes group header'); $this->assertOutputContains('cache:', 'cache group header'); - $this->assertOutputContains('clear', 'cache subcommand listed'); + $this->assertOutputContains('cache clear', 'cache subcommand listed'); $this->assertOutputContains('Clear all data in a single cache engine', 'inline description shown'); $this->assertOutputNotContains('app:', 'no plugin group headers in compact mode'); + $this->assertOutputNotContains('help', 'help command should be hidden'); $this->assertOutputContains('To run a command', 'more info present'); } + /** + * Test that the default header is omitted when Cake version is unknown. + */ + public function testMainCompactOmitsHeaderWhenVersionUnknown(): void + { + $version = Configure::read('Cake.version'); + Configure::write('Cake.version', 'unknown'); + + try { + $this->exec('help'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputNotContains('CakePHP:', 'header should be omitted when version is unknown'); + } finally { + Configure::write('Cake.version', $version); + } + } + /** * Assert the verbose help output. */ protected function assertCommandListVerbose(): void { $this->assertOutputContains('test_plugin', 'plugin header should appear'); - $this->assertOutputContains('- sample', 'plugin command should appear'); + $this->assertOutputContains('sample', 'plugin command should appear'); $this->assertOutputNotContains( '- test_plugin.sample', 'only short alias for plugin command.', @@ -88,18 +109,18 @@ protected function assertCommandListVerbose(): void 'Abstract command classes should not appear.', ); $this->assertOutputContains('app', 'app header should appear'); - $this->assertOutputContains('- sample', 'app shell'); + $this->assertOutputContains('sample', 'app shell'); $this->assertOutputContains('cakephp', 'cakephp header should appear'); - $this->assertOutputContains('- routes', 'core shell'); - $this->assertOutputContains('- sample', 'short plugin name'); - $this->assertOutputContains('- abort', 'command object'); + $this->assertOutputContains('routes', 'core shell'); + $this->assertOutputContains('sample', 'short plugin name'); + $this->assertOutputContains('abort', 'command object'); $this->assertOutputContains('To run a command', 'more info present'); $this->assertOutputContains('To get help', 'more info present'); $this->assertOutputContains('This is a demo command', 'command description missing'); $this->assertOutputContains('custom_group'); - $this->assertOutputContains('- grouped'); + $this->assertOutputContains('grouped'); $this->assertOutputNotContains( - '- hidden', + 'hidden', 'Hidden commands should not appear in help output.', ); } @@ -112,8 +133,8 @@ public function testFilterByPrefixCompact(): void $this->exec('help cache'); $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertOutputContains('cache:'); - $this->assertOutputContains('cache clear'); - $this->assertOutputContains('cache list'); + $this->assertOutputContains('cache clear'); + $this->assertOutputContains('cache list'); $this->assertOutputNotContains('routes'); $this->assertOutputNotContains('sample'); } @@ -126,9 +147,9 @@ public function testFilterByPrefixVerbose(): void $this->exec('help cache -v'); $this->assertExitCode(CommandInterface::CODE_SUCCESS); $this->assertOutputContains('Available Commands'); - $this->assertOutputContains('- cache clear'); + $this->assertOutputContains('cache clear'); $this->assertOutputContains('Clear all data in a single cache engine'); - $this->assertOutputNotContains('- routes'); + $this->assertOutputNotContains('routes'); } /** @@ -149,6 +170,7 @@ public function testMainAsXml(): void $find = 'assertOutputContains($find); + $this->assertOutputNotContains('